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 org;
32pub mod runtime;
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::path::Path;
36
37use serde::{Deserialize, Serialize};
38
39pub mod files;
40
41/// Maximum `extends` chain depth (defense against runaway chains; built-ins
42/// use at most 2).
43const MAX_EXTENDS_DEPTH: usize = 8;
44
45/// Read modes a pack may pin as `default_read_mode` — the documented
46/// `ctx_read` mode vocabulary (range reads like `lines:N-M` are call-site
47/// specific and make no sense as a policy default).
48pub const KNOWN_READ_MODES: &[&str] = &[
49    "auto",
50    "full",
51    "map",
52    "signatures",
53    "diff",
54    "task",
55    "reference",
56    "aggressive",
57    "entropy",
58];
59
60// ── Wire format ──────────────────────────────────────────────────────────────
61
62/// One policy pack as written in TOML. Unknown keys are rejected so a typo
63/// (`alow_tools`) fails validation instead of silently weakening a policy.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct PolicyPack {
67    /// Stable identifier: lowercase, digits and hyphens (`acme-platform`).
68    pub name: String,
69    /// Semantic version of the pack itself (`1.0.0`).
70    pub version: String,
71    /// One-line human description.
72    pub description: String,
73    /// Optional parent pack (built-in name) this pack inherits from.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub extends: Option<String>,
76    /// Context-governance expectations.
77    #[serde(default)]
78    pub context: ContextRules,
79    /// Named redaction patterns: name → regex (matched against content before
80    /// it enters the model context).
81    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
82    pub redaction: BTreeMap<String, String>,
83    /// Inbound content filters (PII / classification / prompt-injection) — the
84    /// input side of the Great Filter (GL #675).
85    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
86    pub filters: FilterRules,
87    /// Egress/output DLP on agent writes & actions (GL #676).
88    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
89    pub egress: EgressRules,
90    /// Gateway routing governance: model allowlist + downgrade exemptions
91    /// (enterprise#25). Enforced by the org gateway under a signed policy.
92    #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
93    pub routing: RoutingPolicyRules,
94    /// Hard org spend caps per person/project (enterprise#25).
95    #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
96    pub budgets: BudgetRules,
97}
98
99/// The `[context]` section of a pack. All fields optional — only what a pack
100/// states is constrained; everything else stays at engine defaults.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct ContextRules {
104    /// Default `ctx_read` mode the policy expects (see [`KNOWN_READ_MODES`]).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub default_read_mode: Option<String>,
107    /// Allowlist of tool names; when set, only these may be called.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub allow_tools: Option<Vec<String>>,
110    /// Denylist of tool names; always additive down the `extends` chain.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub deny_tools: Vec<String>,
113    /// Upper bound on tokens a single context assembly may spend.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub max_context_tokens: Option<u32>,
116    /// Audit-retention expectation in days (governance intent; the hosted
117    /// plane enforces its own plan window — see org-audit-log-v1).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub audit_retention_days: Option<u32>,
120}
121
122/// The `[filters]` section — inbound content detectors (GL #675). Each action
123/// is one of `off` / `warn` / `redact` / `block` (absent ⇒ `off`). Compiled
124/// into a [`crate::core::input_filters::FilterConfig`] at load time and run on
125/// tool output before it reaches the agent.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct FilterRules {
129    /// PII detection (email, phone, payment cards).
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub pii: Option<String>,
132    /// Data-classification marking gate (confidential/secret banners).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub classification: Option<String>,
135    /// Prompt-injection detection (OWASP LLM01) on inbound content.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub injection: Option<String>,
138    /// Classification labels that gate; overrides the built-in default set.
139    /// Accumulates down the `extends` chain (a child may add, never drop).
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub blocked_labels: Vec<String>,
142    /// Named inbound block patterns. Evaluated on original content before any
143    /// masking; inherited and organizational restrictions accumulate.
144    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
145    pub blocked_patterns: BTreeMap<String, String>,
146    /// Ownership of locally published personal rules; never infer ownership from
147    /// a prefix used by an existing hand-authored policy.
148    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
149    pub managed_personal_rules: BTreeSet<String>,
150}
151
152impl FilterRules {
153    /// True when no filter is configured (all actions absent, no labels).
154    #[must_use]
155    pub fn is_empty(&self) -> bool {
156        self.pii.is_none()
157            && self.classification.is_none()
158            && self.injection.is_none()
159            && self.blocked_labels.is_empty()
160            && self.blocked_patterns.is_empty()
161            && self.managed_personal_rules.is_empty()
162    }
163}
164
165/// Same-name restrictions retain both patterns. Bounds are checked again when
166/// compiling the resolved policy; oversized composition denies all access.
167pub(super) fn merge_block_patterns(
168    target: &mut BTreeMap<String, String>,
169    incoming: &BTreeMap<String, String>,
170) {
171    for (name, pattern) in incoming {
172        match target.get_mut(name) {
173            Some(previous) if previous != pattern => {
174                *previous = format!("(?:{previous})|(?:{pattern})");
175            }
176            Some(_) => {}
177            None => {
178                target.insert(name.clone(), pattern.clone());
179            }
180        }
181    }
182}
183
184/// The `[egress]` section — output/DLP enforcement on agent writes & actions
185/// (GL #676). Gates `ctx_edit` writes and `ctx_shell` actions before they
186/// execute. Compiled into a `crate::core::egress::EgressConfig` at load time.
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct EgressRules {
190    /// Regexes that block a write/action when matched (e.g. a prod-DB DSN).
191    /// Accumulates down the `extends` chain.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub forbidden_patterns: Vec<String>,
194    /// Block writes/actions carrying detected secrets or PII.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub block_secrets: Option<bool>,
197    /// Rate limit: max agent write/action tool calls per 60 s.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub max_writes_per_min: Option<u32>,
200}
201
202impl EgressRules {
203    /// True when no egress rule is configured.
204    #[must_use]
205    pub fn is_empty(&self) -> bool {
206        self.forbidden_patterns.is_empty()
207            && self.block_secrets.is_none()
208            && self.max_writes_per_min.is_none()
209    }
210}
211
212/// The `[routing]` section — org gateway routing governance (enterprise#25,
213/// Doc 08 §4.3). Enforced in the gateway forward path when this pack arrives
214/// via a signed, trusted, `enforced = true` [`org::OrgPolicyV1`].
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct RoutingPolicyRules {
218    /// Model allowlist patterns (`"claude-*"`, `"gpt-4o-mini"`). A request for
219    /// a model matching none of them is refused org-wide. Empty = no
220    /// restriction. Accumulates down the `extends` chain (union — the floor
221    /// merge then intersects org vs. local, see `floor`).
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub allowed_models: Vec<String>,
224    /// Additional conjunctive ceilings. Every group must match; an empty
225    /// group denies every model. Preserves glob intersections without turning
226    /// a disjoint intersection into the unrestricted empty primary list.
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub model_ceiling_groups: Vec<Vec<String>>,
229    /// Projects whose requests the router must never downgrade to a cheaper
230    /// tier (`["security", "prod"]`). Accumulates down the chain.
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub forbid_downgrade_for: Vec<String>,
233}
234
235impl RoutingPolicyRules {
236    /// True when no routing governance is configured.
237    #[must_use]
238    pub fn is_empty(&self) -> bool {
239        self.allowed_models.is_empty()
240            && self.model_ceiling_groups.is_empty()
241            && self.forbid_downgrade_for.is_empty()
242    }
243
244    /// Apply all inherited ceilings to one explicitly selected model.
245    #[must_use]
246    pub fn model_allowed(&self, model: &str) -> bool {
247        model_allowed_by_ceilings(&self.allowed_models, &self.model_ceiling_groups, model)
248    }
249}
250
251/// Keep the first occurrence of every exact conjunction, including deny-all
252/// empty groups. Reapplying a floor must not multiply identical checks.
253fn unique_model_ceilings<'a>(groups: impl Iterator<Item = &'a Vec<String>>) -> Vec<Vec<String>> {
254    let mut seen = std::collections::BTreeSet::new();
255    groups
256        .filter(|group| seen.insert(*group))
257        .cloned()
258        .collect()
259}
260
261pub fn model_allowed_by_ceilings(primary: &[String], groups: &[Vec<String>], model: &str) -> bool {
262    let matches = |patterns: &[String]| {
263        patterns
264            .iter()
265            .any(|pattern| model_pattern_matches(pattern, model))
266    };
267    (primary.is_empty() || matches(primary)) && groups.iter().all(|group| matches(group))
268}
269
270/// Glob-lite matching shared by project admission and the gateway.
271pub fn model_pattern_matches(pattern: &str, model: &str) -> bool {
272    let pattern = pattern.trim().as_bytes();
273    let model = model.trim().as_bytes();
274    // Dynamic programming avoids exponential wildcard backtracking.
275    let mut matched = vec![false; model.len() + 1];
276    matched[0] = true;
277    for &byte in pattern {
278        if byte == b'*' {
279            for index in 1..=model.len() {
280                matched[index] |= matched[index - 1];
281            }
282        } else {
283            for index in (1..=model.len()).rev() {
284                matched[index] = matched[index - 1] && model[index - 1] == byte;
285            }
286            matched[0] = false;
287        }
288    }
289    matched[model.len()]
290}
291
292/// The `[budgets]` section — hard org spend caps (enterprise#25, Doc 08 §4.3).
293/// USD amounts against the measured `cost_usd` of the usage meter; breaching a
294/// cap makes the gateway refuse further requests (429) until the window rolls.
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct BudgetRules {
298    /// Max measured spend per person per UTC day.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub max_cost_usd_per_person_per_day: Option<f64>,
301    /// Max measured spend per project per UTC month.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub max_cost_usd_per_project_per_month: Option<f64>,
304    /// Max accepted requests per person per UTC minute (enterprise#66) —
305    /// protects shared upstreams from a single runaway agent. Counted per
306    /// gateway process; multi-replica deployments multiply accordingly.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub max_requests_per_minute_per_person: Option<u32>,
309}
310
311impl BudgetRules {
312    /// True when no cap is configured.
313    #[must_use]
314    pub fn is_empty(&self) -> bool {
315        self.max_cost_usd_per_person_per_day.is_none()
316            && self.max_cost_usd_per_project_per_month.is_none()
317            && self.max_requests_per_minute_per_person.is_none()
318    }
319}
320
321// ── Resolved view ────────────────────────────────────────────────────────────
322
323/// A pack with its full `extends` chain folded in — what enforcement and
324/// `policy show` consume.
325#[derive(Debug, Clone, Serialize)]
326pub struct ResolvedPolicy {
327    pub name: String,
328    pub version: String,
329    pub description: String,
330    /// Inheritance chain, base-most first (`["baseline", "strict-redaction"]`
331    /// for a pack extending `strict-redaction`). Empty for root packs.
332    pub chain: Vec<String>,
333    pub default_read_mode: Option<String>,
334    pub allow_tools: Option<Vec<String>>,
335    pub deny_tools: Vec<String>,
336    pub max_context_tokens: Option<u32>,
337    pub audit_retention_days: Option<u32>,
338    pub redaction: BTreeMap<String, String>,
339    /// Folded inbound-filter actions + label set (GL #675).
340    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
341    pub filters: FilterRules,
342    /// Folded egress/output DLP rules (GL #676).
343    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
344    pub egress: EgressRules,
345    /// Folded gateway routing governance (enterprise#25).
346    #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
347    pub routing: RoutingPolicyRules,
348    /// Folded org spend caps (enterprise#25).
349    #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
350    pub budgets: BudgetRules,
351}
352
353// ── Errors ───────────────────────────────────────────────────────────────────
354
355/// Why a pack failed to parse, validate or resolve. Rendered verbatim by the
356/// CLI, so every variant names the offending field and value.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum PolicyError {
359    Toml(String),
360    InvalidName(String),
361    InvalidVersion(String),
362    EmptyDescription,
363    UnknownReadMode(String),
364    BadRegex { pattern_name: String, error: String },
365    ZeroMaxTokens,
366    AllowDenyOverlap(Vec<String>),
367    UnknownParent(String),
368    ExtendsCycle(Vec<String>),
369    ExtendsTooDeep(usize),
370    UnknownFilterAction { field: String, value: String },
371    InvalidBudget { field: String, value: String },
372    EmptyModelPattern,
373}
374
375impl std::fmt::Display for PolicyError {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        match self {
378            PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
379            PolicyError::InvalidName(n) => write!(
380                f,
381                "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
382            ),
383            PolicyError::InvalidVersion(v) => {
384                write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
385            }
386            PolicyError::EmptyDescription => write!(f, "description must not be empty"),
387            PolicyError::UnknownReadMode(m) => write!(
388                f,
389                "unknown default_read_mode '{m}' (one of: {})",
390                KNOWN_READ_MODES.join(", ")
391            ),
392            PolicyError::BadRegex {
393                pattern_name,
394                error,
395            } => write!(
396                f,
397                "redaction pattern '{pattern_name}' is not a valid regex: {error}"
398            ),
399            PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
400            PolicyError::AllowDenyOverlap(tools) => write!(
401                f,
402                "tools listed in both allow_tools and deny_tools: {}",
403                tools.join(", ")
404            ),
405            PolicyError::UnknownParent(p) => match builtin::paid_tier(p) {
406                Some(_) => write!(f, "extends {}", builtin::unknown_pack_hint(p)),
407                None => write!(
408                    f,
409                    "extends '{p}' does not name a known pack (built-ins: {})",
410                    builtin::names().join(", ")
411                ),
412            },
413            PolicyError::ExtendsCycle(chain) => {
414                write!(f, "extends cycle: {}", chain.join(" -> "))
415            }
416            PolicyError::ExtendsTooDeep(d) => write!(
417                f,
418                "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
419            ),
420            PolicyError::UnknownFilterAction { field, value } => write!(
421                f,
422                "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
423            ),
424            PolicyError::InvalidBudget { field, value } => write!(
425                f,
426                "budgets.{field} must be a positive, finite USD amount (got {value})"
427            ),
428            PolicyError::EmptyModelPattern => {
429                write!(f, "routing.allowed_models must not contain empty patterns")
430            }
431        }
432    }
433}
434
435impl std::error::Error for PolicyError {}
436
437// ── Parse + validate ─────────────────────────────────────────────────────────
438
439/// Parse one pack from TOML text (no I/O) and validate it standalone.
440/// `extends` is checked against the built-ins during [`resolve`].
441pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
442    let pack: PolicyPack =
443        toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
444    validate(&pack)?;
445    Ok(pack)
446}
447
448/// Parse a pack from a file path. Read errors surface as [`PolicyError::Toml`]
449/// with the OS message — the CLI shows them verbatim.
450pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
451    let text = files::read(path, true)
452        .map_err(PolicyError::Toml)?
453        .ok_or_else(|| PolicyError::Toml("policy input is missing".into()))?;
454    parse(&text)
455}
456
457/// Field-level validation of a single (unresolved) pack.
458/// Label prefix of the rules a Hive filter pack adds.
459pub(crate) const HIVE_LABEL_PREFIX: &str = "hive:";
460
461pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
462    if pack.name.is_empty()
463        || !pack
464            .name
465            .bytes()
466            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
467        || pack.name.starts_with('-')
468        || pack.name.ends_with('-')
469    {
470        return Err(PolicyError::InvalidName(pack.name.clone()));
471    }
472    if !valid_semver(&pack.version) {
473        return Err(PolicyError::InvalidVersion(pack.version.clone()));
474    }
475    if pack.description.trim().is_empty() {
476        return Err(PolicyError::EmptyDescription);
477    }
478    if let Some(mode) = pack.context.default_read_mode.as_deref()
479        && !KNOWN_READ_MODES.contains(&mode)
480    {
481        return Err(PolicyError::UnknownReadMode(mode.to_string()));
482    }
483    if let Some(max) = pack.context.max_context_tokens
484        && max == 0
485    {
486        return Err(PolicyError::ZeroMaxTokens);
487    }
488    if let Some(allow) = &pack.context.allow_tools {
489        let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
490        let overlap: Vec<String> = allow
491            .iter()
492            .filter(|t| deny.contains(t.as_str()))
493            .cloned()
494            .collect();
495        if !overlap.is_empty() {
496            return Err(PolicyError::AllowDenyOverlap(overlap));
497        }
498    }
499    if pack.redaction.len() + pack.filters.blocked_patterns.len() > content::MAX_REDACTION_RULES
500        || pack.egress.forbidden_patterns.len() > content::MAX_REDACTION_RULES
501        || pack.filters.managed_personal_rules.len() > 32
502    {
503        return Err(PolicyError::BadRegex {
504            pattern_name: "policy".into(),
505            error: "too many policy patterns".into(),
506        });
507    }
508    // `hive:` labels belong to the Hive filter packs; a pack using one
509    // could shadow or be shadowed by a Hive rule.
510    if pack
511        .redaction
512        .keys()
513        .chain(pack.filters.blocked_patterns.keys())
514        .any(|label| label.starts_with(HIVE_LABEL_PREFIX))
515    {
516        return Err(PolicyError::BadRegex {
517            pattern_name: "policy".into(),
518            error: format!(
519                "rule labels starting with `{HIVE_LABEL_PREFIX}` are reserved for Hive filter packs"
520            ),
521        });
522    }
523    if pack.filters.managed_personal_rules.iter().any(|name| {
524        !name.starts_with("personal_")
525            || name.len() <= 9
526            || name.len() > content::MAX_RULE_LABEL_BYTES
527            || !name
528                .bytes()
529                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-".contains(&b))
530            || (!pack.redaction.contains_key(name)
531                && !pack.filters.blocked_patterns.contains_key(name))
532    }) {
533        return Err(PolicyError::BadRegex {
534            pattern_name: "policy".into(),
535            error: "invalid personal rule ownership".into(),
536        });
537    }
538    for (name, pattern) in pack.redaction.iter().chain(&pack.filters.blocked_patterns) {
539        if name.is_empty()
540            || name.len() > content::MAX_RULE_LABEL_BYTES
541            || name.chars().any(char::is_control)
542            || pattern.len() > content::MAX_RULE_PATTERN_BYTES
543        {
544            return Err(PolicyError::BadRegex {
545                pattern_name: "redaction".into(),
546                error: "policy pattern bounds exceeded".into(),
547            });
548        }
549        if let Err(e) = regex::RegexBuilder::new(pattern)
550            .size_limit(1024 * 1024)
551            .dfa_size_limit(1024 * 1024)
552            .build()
553        {
554            return Err(PolicyError::BadRegex {
555                pattern_name: name.clone(),
556                error: e.to_string(),
557            });
558        }
559    }
560    validate_filter_action("pii", pack.filters.pii.as_deref())?;
561    validate_filter_action("classification", pack.filters.classification.as_deref())?;
562    validate_filter_action("injection", pack.filters.injection.as_deref())?;
563    for pattern in &pack.egress.forbidden_patterns {
564        if pattern.len() > content::MAX_RULE_PATTERN_BYTES {
565            return Err(PolicyError::BadRegex {
566                pattern_name: "egress.forbidden_patterns".into(),
567                error: "policy pattern bounds exceeded".into(),
568            });
569        }
570        if let Err(e) = regex::RegexBuilder::new(pattern)
571            .size_limit(1024 * 1024)
572            .dfa_size_limit(1024 * 1024)
573            .build()
574        {
575            return Err(PolicyError::BadRegex {
576                pattern_name: format!("egress.forbidden_patterns: {pattern}"),
577                error: e.to_string(),
578            });
579        }
580    }
581    if pack
582        .routing
583        .allowed_models
584        .iter()
585        .chain(pack.routing.model_ceiling_groups.iter().flatten())
586        .any(|p| p.trim().is_empty())
587    {
588        return Err(PolicyError::EmptyModelPattern);
589    }
590    validate_budget(
591        "max_cost_usd_per_person_per_day",
592        pack.budgets.max_cost_usd_per_person_per_day,
593    )?;
594    validate_budget(
595        "max_cost_usd_per_project_per_month",
596        pack.budgets.max_cost_usd_per_project_per_month,
597    )?;
598    Ok(())
599}
600
601/// A budget cap must be a positive, finite USD amount.
602fn validate_budget(field: &str, value: Option<f64>) -> Result<(), PolicyError> {
603    if let Some(v) = value
604        && !(v.is_finite() && v > 0.0)
605    {
606        return Err(PolicyError::InvalidBudget {
607            field: field.to_string(),
608            value: v.to_string(),
609        });
610    }
611    Ok(())
612}
613
614/// Reject a `[filters]` action that is not a known token.
615fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
616    if let Some(v) = value
617        && crate::core::input_filters::FilterAction::parse(v).is_none()
618    {
619        return Err(PolicyError::UnknownFilterAction {
620            field: field.to_string(),
621            value: v.to_string(),
622        });
623    }
624    Ok(())
625}
626
627/// `MAJOR.MINOR.PATCH`, digits only — packs don't need pre-release tags.
628fn valid_semver(v: &str) -> bool {
629    let parts: Vec<&str> = v.split('.').collect();
630    parts.len() == 3
631        && parts
632            .iter()
633            .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
634}
635
636// ── Resolve (extends) ────────────────────────────────────────────────────────
637
638/// Fold a pack's `extends` chain (against the built-ins) into one
639/// [`ResolvedPolicy`]. See the module docs for the inheritance semantics.
640pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
641    // Walk to the root, collecting the chain (child first).
642    let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
643    let mut seen: Vec<String> = vec![pack.name.clone()];
644    let mut next_parent = pack.extends.clone();
645    while let Some(parent_name) = next_parent.take() {
646        if seen.contains(&parent_name) {
647            seen.push(parent_name);
648            return Err(PolicyError::ExtendsCycle(seen));
649        }
650        if lineage.len() >= MAX_EXTENDS_DEPTH {
651            return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
652        }
653        let parent =
654            builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
655        seen.push(parent_name);
656        next_parent.clone_from(&parent.extends);
657        lineage.push(parent);
658    }
659
660    // Fold base-most first so children override scalars and accumulate
661    // restrictions on top.
662    let mut resolved = ResolvedPolicy {
663        name: pack.name.clone(),
664        version: pack.version.clone(),
665        description: pack.description.clone(),
666        chain: seen.iter().skip(1).rev().cloned().collect(),
667        default_read_mode: None,
668        allow_tools: None,
669        deny_tools: Vec::new(),
670        max_context_tokens: None,
671        audit_retention_days: None,
672        redaction: BTreeMap::new(),
673        filters: FilterRules::default(),
674        egress: EgressRules::default(),
675        routing: RoutingPolicyRules::default(),
676        budgets: BudgetRules::default(),
677    };
678    for layer in lineage.iter().rev() {
679        if let Some(mode) = &layer.context.default_read_mode {
680            resolved.default_read_mode = Some(mode.clone());
681        }
682        if let Some(allow) = &layer.context.allow_tools {
683            resolved.allow_tools = Some(allow.clone());
684        }
685        for tool in &layer.context.deny_tools {
686            if !resolved.deny_tools.contains(tool) {
687                resolved.deny_tools.push(tool.clone());
688            }
689        }
690        if let Some(max) = layer.context.max_context_tokens {
691            resolved.max_context_tokens = Some(max);
692        }
693        if let Some(days) = layer.context.audit_retention_days {
694            resolved.audit_retention_days = Some(days);
695        }
696        for (name, pattern) in &layer.redaction {
697            resolved.redaction.insert(name.clone(), pattern.clone());
698        }
699        // Filter actions override (child wins); labels accumulate.
700        if let Some(v) = &layer.filters.pii {
701            resolved.filters.pii = Some(v.clone());
702        }
703        if let Some(v) = &layer.filters.classification {
704            resolved.filters.classification = Some(v.clone());
705        }
706        if let Some(v) = &layer.filters.injection {
707            resolved.filters.injection = Some(v.clone());
708        }
709        for label in &layer.filters.blocked_labels {
710            if !resolved.filters.blocked_labels.contains(label) {
711                resolved.filters.blocked_labels.push(label.clone());
712            }
713        }
714        merge_block_patterns(
715            &mut resolved.filters.blocked_patterns,
716            &layer.filters.blocked_patterns,
717        );
718        resolved
719            .filters
720            .managed_personal_rules
721            .clone_from(&layer.filters.managed_personal_rules);
722        // Egress: forbidden patterns accumulate; scalars override (child wins).
723        for pattern in &layer.egress.forbidden_patterns {
724            if !resolved.egress.forbidden_patterns.contains(pattern) {
725                resolved.egress.forbidden_patterns.push(pattern.clone());
726            }
727        }
728        if let Some(v) = layer.egress.block_secrets {
729            resolved.egress.block_secrets = Some(v);
730        }
731        if let Some(v) = layer.egress.max_writes_per_min {
732            resolved.egress.max_writes_per_min = Some(v);
733        }
734        // Routing governance: restriction lists accumulate down the chain.
735        for pattern in &layer.routing.allowed_models {
736            if !resolved.routing.allowed_models.contains(pattern) {
737                resolved.routing.allowed_models.push(pattern.clone());
738            }
739        }
740        resolved.routing.model_ceiling_groups = unique_model_ceilings(
741            resolved
742                .routing
743                .model_ceiling_groups
744                .iter()
745                .chain(layer.routing.model_ceiling_groups.iter()),
746        );
747        for project in &layer.routing.forbid_downgrade_for {
748            if !resolved.routing.forbid_downgrade_for.contains(project) {
749                resolved.routing.forbid_downgrade_for.push(project.clone());
750            }
751        }
752        // Budgets: scalar caps override (child wins) — the floor merge takes
753        // the stricter side across org vs. local separately.
754        if let Some(v) = layer.budgets.max_cost_usd_per_person_per_day {
755            resolved.budgets.max_cost_usd_per_person_per_day = Some(v);
756        }
757        if let Some(v) = layer.budgets.max_cost_usd_per_project_per_month {
758            resolved.budgets.max_cost_usd_per_project_per_month = Some(v);
759        }
760    }
761
762    // A resolved allowlist must not collide with accumulated denies.
763    if let Some(allow) = &resolved.allow_tools {
764        let overlap: Vec<String> = allow
765            .iter()
766            .filter(|t| resolved.deny_tools.contains(*t))
767            .cloned()
768            .collect();
769        if !overlap.is_empty() {
770            return Err(PolicyError::AllowDenyOverlap(overlap));
771        }
772    }
773    Ok(resolved)
774}
775
776/// Parse + validate + resolve in one step — the common CLI path.
777pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
778    resolve(&parse(toml_text)?)
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
786        PolicyPack {
787            name: name.to_string(),
788            version: "1.0.0".to_string(),
789            description: "test pack".to_string(),
790            extends: extends.map(str::to_string),
791            context: ContextRules::default(),
792            redaction: BTreeMap::new(),
793            filters: FilterRules::default(),
794            egress: EgressRules::default(),
795            routing: RoutingPolicyRules::default(),
796            budgets: BudgetRules::default(),
797        }
798    }
799
800    #[test]
801    fn parses_a_full_pack() {
802        let pack = parse(
803            r#"
804name = "acme-internal"
805version = "2.1.0"
806description = "ACME internal baseline"
807extends = "strict-redaction"
808
809[context]
810default_read_mode = "map"
811deny_tools = ["ctx_url_read"]
812max_context_tokens = 12000
813audit_retention_days = 365
814
815[redaction]
816employee_id = 'EMP-\d{6}'
817"#,
818        )
819        .expect("parses");
820        assert_eq!(pack.name, "acme-internal");
821        assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
822        assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
823        assert!(pack.redaction.contains_key("employee_id"));
824    }
825
826    #[test]
827    fn parses_gateway_governance_sections() {
828        // enterprise#25: [routing] + [budgets] ride inside the same signed pack.
829        let resolved = load(
830            r#"
831name = "acme-gateway"
832version = "1.0.0"
833description = "org gateway governance"
834
835[routing]
836allowed_models = ["claude-*", "gpt-4o-mini"]
837forbid_downgrade_for = ["prod"]
838
839[budgets]
840max_cost_usd_per_person_per_day = 50.0
841max_cost_usd_per_project_per_month = 20000.0
842"#,
843        )
844        .expect("resolves");
845        assert_eq!(
846            resolved.routing.allowed_models,
847            vec!["claude-*".to_string(), "gpt-4o-mini".to_string()]
848        );
849        assert_eq!(resolved.routing.forbid_downgrade_for, vec!["prod"]);
850        assert_eq!(resolved.budgets.max_cost_usd_per_person_per_day, Some(50.0));
851        assert_eq!(
852            resolved.budgets.max_cost_usd_per_project_per_month,
853            Some(20000.0)
854        );
855    }
856
857    #[test]
858    fn rejects_invalid_budget_and_empty_model_pattern() {
859        let neg = parse(
860            r#"
861name = "bad-budget"
862version = "1.0.0"
863description = "x"
864
865[budgets]
866max_cost_usd_per_person_per_day = -5.0
867"#,
868        );
869        assert!(matches!(neg, Err(PolicyError::InvalidBudget { .. })));
870
871        let empty = parse(
872            r#"
873name = "bad-pattern"
874version = "1.0.0"
875description = "x"
876
877[routing]
878allowed_models = ["claude-*", " "]
879"#,
880        );
881        assert_eq!(empty.unwrap_err(), PolicyError::EmptyModelPattern);
882    }
883
884    #[test]
885    fn unknown_keys_are_rejected() {
886        let err = parse(
887            r#"
888name = "typo"
889version = "1.0.0"
890description = "x"
891
892[context]
893alow_tools = ["ctx_read"]
894"#,
895        )
896        .unwrap_err();
897        assert!(matches!(err, PolicyError::Toml(_)), "{err}");
898    }
899
900    #[test]
901    fn validation_catches_each_field() {
902        let mut p = minimal("Bad Name", None);
903        assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
904
905        p = minimal("ok", None);
906        p.version = "1.0".into();
907        assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
908
909        p = minimal("ok", None);
910        p.description = "  ".into();
911        assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
912
913        p = minimal("ok", None);
914        p.context.default_read_mode = Some("lines:1-5".into());
915        assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
916
917        p = minimal("ok", None);
918        p.context.max_context_tokens = Some(0);
919        assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
920
921        p = minimal("ok", None);
922        p.redaction.insert("broken".into(), "(unclosed".into());
923        assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
924
925        p = minimal("ok", None);
926        p.context.allow_tools = Some(vec!["ctx_read".into()]);
927        p.context.deny_tools = vec!["ctx_read".into()];
928        assert!(matches!(
929            validate(&p),
930            Err(PolicyError::AllowDenyOverlap(_))
931        ));
932    }
933
934    #[test]
935    fn resolve_overrides_scalars_and_accumulates_denies() {
936        let mut child = minimal("child", Some("strict-redaction"));
937        child.context.default_read_mode = Some("signatures".into());
938        child.context.deny_tools = vec!["ctx_url_read".into(), "ctx_shell".into()];
939        child
940            .redaction
941            .insert("employee_id".into(), r"EMP-\d{4}".into());
942        let r = resolve(&child).expect("resolves");
943
944        // Scalar overridden by the child.
945        assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
946        // The child's denies apply.
947        assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
948        assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
949        // Redaction accumulated from the whole chain (baseline + strict + child).
950        assert!(r.redaction.contains_key("employee_id"));
951        assert!(r.redaction.contains_key("private_key"));
952        // Chain is base-most first and excludes the pack itself.
953        assert_eq!(r.chain, vec!["baseline", "strict-redaction"]);
954    }
955
956    #[test]
957    fn resolve_rejects_unknown_parent_and_cycle() {
958        let p = minimal("orphan", Some("no-such-pack"));
959        assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
960
961        // A former built-in that moved to a paid tier says so.
962        let p = minimal("regulated", Some("healthcare"));
963        let err = resolve(&p).expect_err("not bundled");
964        assert!(err.to_string().contains("LeanCTX | Hive"), "{err}");
965
966        // Self-reference is the minimal cycle reachable without registering
967        // custom packs (built-ins are acyclic by construction + test below).
968        let p = minimal("loop", Some("loop"));
969        assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
970    }
971
972    #[test]
973    fn child_redaction_overrides_same_named_parent_pattern() {
974        let mut child = minimal("child", Some("baseline"));
975        child
976            .redaction
977            .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
978        let r = resolve(&child).expect("resolves");
979        assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
980    }
981}