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}
235
236impl RoutingPolicyRules {
237    /// True when no routing governance is configured.
238    #[must_use]
239    pub fn is_empty(&self) -> bool {
240        self.allowed_models.is_empty()
241            && self.model_ceiling_groups.is_empty()
242            && self.forbid_downgrade_for.is_empty()
243    }
244
245    /// Apply all inherited ceilings to one explicitly selected model.
246    #[must_use]
247    pub fn model_allowed(&self, model: &str) -> bool {
248        model_allowed_by_ceilings(&self.allowed_models, &self.model_ceiling_groups, model)
249    }
250}
251
252/// Keep the first occurrence of every exact conjunction, including deny-all
253/// empty groups. Reapplying a floor must not multiply identical checks.
254fn unique_model_ceilings<'a>(groups: impl Iterator<Item = &'a Vec<String>>) -> Vec<Vec<String>> {
255    let mut seen = std::collections::BTreeSet::new();
256    groups
257        .filter(|group| seen.insert(*group))
258        .cloned()
259        .collect()
260}
261
262pub fn model_allowed_by_ceilings(primary: &[String], groups: &[Vec<String>], model: &str) -> bool {
263    let matches = |patterns: &[String]| {
264        patterns
265            .iter()
266            .any(|pattern| model_pattern_matches(pattern, model))
267    };
268    (primary.is_empty() || matches(primary)) && groups.iter().all(|group| matches(group))
269}
270
271/// Glob-lite matching shared by project admission and the gateway.
272pub fn model_pattern_matches(pattern: &str, model: &str) -> bool {
273    let pattern = pattern.trim().as_bytes();
274    let model = model.trim().as_bytes();
275    // Dynamic programming avoids exponential wildcard backtracking.
276    let mut matched = vec![false; model.len() + 1];
277    matched[0] = true;
278    for &byte in pattern {
279        if byte == b'*' {
280            for index in 1..=model.len() {
281                matched[index] |= matched[index - 1];
282            }
283        } else {
284            for index in (1..=model.len()).rev() {
285                matched[index] = matched[index - 1] && model[index - 1] == byte;
286            }
287            matched[0] = false;
288        }
289    }
290    matched[model.len()]
291}
292
293/// The `[budgets]` section — hard org spend caps (enterprise#25, Doc 08 §4.3).
294/// USD amounts against the measured `cost_usd` of the usage meter; breaching a
295/// cap makes the gateway refuse further requests (429) until the window rolls.
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub struct BudgetRules {
299    /// Max measured spend per person per UTC day.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub max_cost_usd_per_person_per_day: Option<f64>,
302    /// Max measured spend per project per UTC month.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub max_cost_usd_per_project_per_month: Option<f64>,
305    /// Max accepted requests per person per UTC minute (enterprise#66) —
306    /// protects shared upstreams from a single runaway agent. Counted per
307    /// gateway process; multi-replica deployments multiply accordingly.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub max_requests_per_minute_per_person: Option<u32>,
310}
311
312impl BudgetRules {
313    /// True when no cap is configured.
314    #[must_use]
315    pub fn is_empty(&self) -> bool {
316        self.max_cost_usd_per_person_per_day.is_none()
317            && self.max_cost_usd_per_project_per_month.is_none()
318            && self.max_requests_per_minute_per_person.is_none()
319    }
320}
321
322// ── Resolved view ────────────────────────────────────────────────────────────
323
324/// A pack with its full `extends` chain folded in — what enforcement and
325/// `policy show` consume.
326#[derive(Debug, Clone, Serialize)]
327pub struct ResolvedPolicy {
328    pub name: String,
329    pub version: String,
330    pub description: String,
331    /// Inheritance chain, base-most first (`["baseline", "strict-redaction"]`
332    /// for a pack extending `strict-redaction`). Empty for root packs.
333    pub chain: Vec<String>,
334    pub default_read_mode: Option<String>,
335    pub allow_tools: Option<Vec<String>>,
336    pub deny_tools: Vec<String>,
337    pub max_context_tokens: Option<u32>,
338    pub audit_retention_days: Option<u32>,
339    pub redaction: BTreeMap<String, String>,
340    /// Folded inbound-filter actions + label set (GL #675).
341    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
342    pub filters: FilterRules,
343    /// Folded egress/output DLP rules (GL #676).
344    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
345    pub egress: EgressRules,
346    /// Folded gateway routing governance (enterprise#25).
347    #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
348    pub routing: RoutingPolicyRules,
349    /// Folded org spend caps (enterprise#25).
350    #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
351    pub budgets: BudgetRules,
352}
353
354// ── Errors ───────────────────────────────────────────────────────────────────
355
356/// Why a pack failed to parse, validate or resolve. Rendered verbatim by the
357/// CLI, so every variant names the offending field and value.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum PolicyError {
360    Toml(String),
361    InvalidName(String),
362    InvalidVersion(String),
363    EmptyDescription,
364    UnknownReadMode(String),
365    BadRegex { pattern_name: String, error: String },
366    ZeroMaxTokens,
367    AllowDenyOverlap(Vec<String>),
368    UnknownParent(String),
369    ExtendsCycle(Vec<String>),
370    ExtendsTooDeep(usize),
371    UnknownFilterAction { field: String, value: String },
372    InvalidBudget { field: String, value: String },
373    EmptyModelPattern,
374}
375
376impl std::fmt::Display for PolicyError {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        match self {
379            PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
380            PolicyError::InvalidName(n) => write!(
381                f,
382                "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
383            ),
384            PolicyError::InvalidVersion(v) => {
385                write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
386            }
387            PolicyError::EmptyDescription => write!(f, "description must not be empty"),
388            PolicyError::UnknownReadMode(m) => write!(
389                f,
390                "unknown default_read_mode '{m}' (one of: {})",
391                KNOWN_READ_MODES.join(", ")
392            ),
393            PolicyError::BadRegex {
394                pattern_name,
395                error,
396            } => write!(
397                f,
398                "redaction pattern '{pattern_name}' is not a valid regex: {error}"
399            ),
400            PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
401            PolicyError::AllowDenyOverlap(tools) => write!(
402                f,
403                "tools listed in both allow_tools and deny_tools: {}",
404                tools.join(", ")
405            ),
406            PolicyError::UnknownParent(p) => match builtin::paid_tier(p) {
407                Some(_) => write!(f, "extends {}", builtin::unknown_pack_hint(p)),
408                None => write!(
409                    f,
410                    "extends '{p}' does not name a known pack (built-ins: {})",
411                    builtin::names().join(", ")
412                ),
413            },
414            PolicyError::ExtendsCycle(chain) => {
415                write!(f, "extends cycle: {}", chain.join(" -> "))
416            }
417            PolicyError::ExtendsTooDeep(d) => write!(
418                f,
419                "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
420            ),
421            PolicyError::UnknownFilterAction { field, value } => write!(
422                f,
423                "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
424            ),
425            PolicyError::InvalidBudget { field, value } => write!(
426                f,
427                "budgets.{field} must be a positive, finite USD amount (got {value})"
428            ),
429            PolicyError::EmptyModelPattern => {
430                write!(f, "routing.allowed_models must not contain empty patterns")
431            }
432        }
433    }
434}
435
436impl std::error::Error for PolicyError {}
437
438// ── Parse + validate ─────────────────────────────────────────────────────────
439
440/// Parse one pack from TOML text (no I/O) and validate it standalone.
441/// `extends` is checked against the built-ins during [`resolve`].
442pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
443    let pack: PolicyPack =
444        toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
445    validate(&pack)?;
446    Ok(pack)
447}
448
449/// Parse a pack from a file path. Read errors surface as [`PolicyError::Toml`]
450/// with the OS message — the CLI shows them verbatim.
451pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
452    let text = files::read(path, true)
453        .map_err(PolicyError::Toml)?
454        .ok_or_else(|| PolicyError::Toml("policy input is missing".into()))?;
455    parse(&text)
456}
457
458/// Field-level validation of a single (unresolved) pack.
459/// Label prefix of the rules a Hive filter pack adds.
460pub(crate) const HIVE_LABEL_PREFIX: &str = "hive:";
461
462pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
463    if pack.name.is_empty()
464        || !pack
465            .name
466            .bytes()
467            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
468        || pack.name.starts_with('-')
469        || pack.name.ends_with('-')
470    {
471        return Err(PolicyError::InvalidName(pack.name.clone()));
472    }
473    if !valid_semver(&pack.version) {
474        return Err(PolicyError::InvalidVersion(pack.version.clone()));
475    }
476    if pack.description.trim().is_empty() {
477        return Err(PolicyError::EmptyDescription);
478    }
479    if let Some(mode) = pack.context.default_read_mode.as_deref()
480        && !KNOWN_READ_MODES.contains(&mode)
481    {
482        return Err(PolicyError::UnknownReadMode(mode.to_string()));
483    }
484    if let Some(max) = pack.context.max_context_tokens
485        && max == 0
486    {
487        return Err(PolicyError::ZeroMaxTokens);
488    }
489    if let Some(allow) = &pack.context.allow_tools {
490        let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
491        let overlap: Vec<String> = allow
492            .iter()
493            .filter(|t| deny.contains(t.as_str()))
494            .cloned()
495            .collect();
496        if !overlap.is_empty() {
497            return Err(PolicyError::AllowDenyOverlap(overlap));
498        }
499    }
500    if pack.redaction.len() + pack.filters.blocked_patterns.len() > content::MAX_REDACTION_RULES
501        || pack.egress.forbidden_patterns.len() > content::MAX_REDACTION_RULES
502        || pack.filters.managed_personal_rules.len() > 32
503    {
504        return Err(PolicyError::BadRegex {
505            pattern_name: "policy".into(),
506            error: "too many policy patterns".into(),
507        });
508    }
509    // `hive:` labels belong to the Hive filter packs; a pack using one
510    // could shadow or be shadowed by a Hive rule.
511    if pack
512        .redaction
513        .keys()
514        .chain(pack.filters.blocked_patterns.keys())
515        .any(|label| label.starts_with(HIVE_LABEL_PREFIX))
516    {
517        return Err(PolicyError::BadRegex {
518            pattern_name: "policy".into(),
519            error: format!(
520                "rule labels starting with `{HIVE_LABEL_PREFIX}` are reserved for Hive filter packs"
521            ),
522        });
523    }
524    if pack.filters.managed_personal_rules.iter().any(|name| {
525        !name.starts_with("personal_")
526            || name.len() <= 9
527            || name.len() > content::MAX_RULE_LABEL_BYTES
528            || !name
529                .bytes()
530                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-".contains(&b))
531            || (!pack.redaction.contains_key(name)
532                && !pack.filters.blocked_patterns.contains_key(name))
533    }) {
534        return Err(PolicyError::BadRegex {
535            pattern_name: "policy".into(),
536            error: "invalid personal rule ownership".into(),
537        });
538    }
539    for (name, pattern) in pack.redaction.iter().chain(&pack.filters.blocked_patterns) {
540        if name.is_empty()
541            || name.len() > content::MAX_RULE_LABEL_BYTES
542            || name.chars().any(char::is_control)
543            || pattern.len() > content::MAX_RULE_PATTERN_BYTES
544        {
545            return Err(PolicyError::BadRegex {
546                pattern_name: "redaction".into(),
547                error: "policy pattern bounds exceeded".into(),
548            });
549        }
550        if let Err(e) = regex::RegexBuilder::new(pattern)
551            .size_limit(1024 * 1024)
552            .dfa_size_limit(1024 * 1024)
553            .build()
554        {
555            return Err(PolicyError::BadRegex {
556                pattern_name: name.clone(),
557                error: e.to_string(),
558            });
559        }
560    }
561    validate_filter_action("pii", pack.filters.pii.as_deref())?;
562    validate_filter_action("classification", pack.filters.classification.as_deref())?;
563    validate_filter_action("injection", pack.filters.injection.as_deref())?;
564    for pattern in &pack.egress.forbidden_patterns {
565        if pattern.len() > content::MAX_RULE_PATTERN_BYTES {
566            return Err(PolicyError::BadRegex {
567                pattern_name: "egress.forbidden_patterns".into(),
568                error: "policy pattern bounds exceeded".into(),
569            });
570        }
571        if let Err(e) = regex::RegexBuilder::new(pattern)
572            .size_limit(1024 * 1024)
573            .dfa_size_limit(1024 * 1024)
574            .build()
575        {
576            return Err(PolicyError::BadRegex {
577                pattern_name: format!("egress.forbidden_patterns: {pattern}"),
578                error: e.to_string(),
579            });
580        }
581    }
582    if pack
583        .routing
584        .allowed_models
585        .iter()
586        .chain(pack.routing.model_ceiling_groups.iter().flatten())
587        .any(|p| p.trim().is_empty())
588    {
589        return Err(PolicyError::EmptyModelPattern);
590    }
591    validate_budget(
592        "max_cost_usd_per_person_per_day",
593        pack.budgets.max_cost_usd_per_person_per_day,
594    )?;
595    validate_budget(
596        "max_cost_usd_per_project_per_month",
597        pack.budgets.max_cost_usd_per_project_per_month,
598    )?;
599    Ok(())
600}
601
602/// A budget cap must be a positive, finite USD amount.
603fn validate_budget(field: &str, value: Option<f64>) -> Result<(), PolicyError> {
604    if let Some(v) = value
605        && !(v.is_finite() && v > 0.0)
606    {
607        return Err(PolicyError::InvalidBudget {
608            field: field.to_string(),
609            value: v.to_string(),
610        });
611    }
612    Ok(())
613}
614
615/// Reject a `[filters]` action that is not a known token.
616fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
617    if let Some(v) = value
618        && crate::core::input_filters::FilterAction::parse(v).is_none()
619    {
620        return Err(PolicyError::UnknownFilterAction {
621            field: field.to_string(),
622            value: v.to_string(),
623        });
624    }
625    Ok(())
626}
627
628/// `MAJOR.MINOR.PATCH`, digits only — packs don't need pre-release tags.
629fn valid_semver(v: &str) -> bool {
630    let parts: Vec<&str> = v.split('.').collect();
631    parts.len() == 3
632        && parts
633            .iter()
634            .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
635}
636
637// ── Resolve (extends) ────────────────────────────────────────────────────────
638
639/// Fold a pack's `extends` chain (against the built-ins) into one
640/// [`ResolvedPolicy`]. See the module docs for the inheritance semantics.
641pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
642    // Walk to the root, collecting the chain (child first).
643    let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
644    let mut seen: Vec<String> = vec![pack.name.clone()];
645    let mut next_parent = pack.extends.clone();
646    while let Some(parent_name) = next_parent.take() {
647        if seen.contains(&parent_name) {
648            seen.push(parent_name);
649            return Err(PolicyError::ExtendsCycle(seen));
650        }
651        if lineage.len() >= MAX_EXTENDS_DEPTH {
652            return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
653        }
654        let parent =
655            builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
656        seen.push(parent_name);
657        next_parent.clone_from(&parent.extends);
658        lineage.push(parent);
659    }
660
661    // Fold base-most first so children override scalars and accumulate
662    // restrictions on top.
663    let mut resolved = ResolvedPolicy {
664        name: pack.name.clone(),
665        version: pack.version.clone(),
666        description: pack.description.clone(),
667        chain: seen.iter().skip(1).rev().cloned().collect(),
668        default_read_mode: None,
669        allow_tools: None,
670        deny_tools: Vec::new(),
671        max_context_tokens: None,
672        audit_retention_days: None,
673        redaction: BTreeMap::new(),
674        filters: FilterRules::default(),
675        egress: EgressRules::default(),
676        routing: RoutingPolicyRules::default(),
677        budgets: BudgetRules::default(),
678    };
679    for layer in lineage.iter().rev() {
680        if let Some(mode) = &layer.context.default_read_mode {
681            resolved.default_read_mode = Some(mode.clone());
682        }
683        if let Some(allow) = &layer.context.allow_tools {
684            resolved.allow_tools = Some(allow.clone());
685        }
686        for tool in &layer.context.deny_tools {
687            if !resolved.deny_tools.contains(tool) {
688                resolved.deny_tools.push(tool.clone());
689            }
690        }
691        if let Some(max) = layer.context.max_context_tokens {
692            resolved.max_context_tokens = Some(max);
693        }
694        if let Some(days) = layer.context.audit_retention_days {
695            resolved.audit_retention_days = Some(days);
696        }
697        for (name, pattern) in &layer.redaction {
698            resolved.redaction.insert(name.clone(), pattern.clone());
699        }
700        // Filter actions override (child wins); labels accumulate.
701        if let Some(v) = &layer.filters.pii {
702            resolved.filters.pii = Some(v.clone());
703        }
704        if let Some(v) = &layer.filters.classification {
705            resolved.filters.classification = Some(v.clone());
706        }
707        if let Some(v) = &layer.filters.injection {
708            resolved.filters.injection = Some(v.clone());
709        }
710        for label in &layer.filters.blocked_labels {
711            if !resolved.filters.blocked_labels.contains(label) {
712                resolved.filters.blocked_labels.push(label.clone());
713            }
714        }
715        merge_block_patterns(
716            &mut resolved.filters.blocked_patterns,
717            &layer.filters.blocked_patterns,
718        );
719        resolved
720            .filters
721            .managed_personal_rules
722            .clone_from(&layer.filters.managed_personal_rules);
723        // Egress: forbidden patterns accumulate; scalars override (child wins).
724        for pattern in &layer.egress.forbidden_patterns {
725            if !resolved.egress.forbidden_patterns.contains(pattern) {
726                resolved.egress.forbidden_patterns.push(pattern.clone());
727            }
728        }
729        if let Some(v) = layer.egress.block_secrets {
730            resolved.egress.block_secrets = Some(v);
731        }
732        if let Some(v) = layer.egress.max_writes_per_min {
733            resolved.egress.max_writes_per_min = Some(v);
734        }
735        // Routing governance: restriction lists accumulate down the chain.
736        for pattern in &layer.routing.allowed_models {
737            if !resolved.routing.allowed_models.contains(pattern) {
738                resolved.routing.allowed_models.push(pattern.clone());
739            }
740        }
741        resolved.routing.model_ceiling_groups = unique_model_ceilings(
742            resolved
743                .routing
744                .model_ceiling_groups
745                .iter()
746                .chain(layer.routing.model_ceiling_groups.iter()),
747        );
748        for project in &layer.routing.forbid_downgrade_for {
749            if !resolved.routing.forbid_downgrade_for.contains(project) {
750                resolved.routing.forbid_downgrade_for.push(project.clone());
751            }
752        }
753        // Budgets: scalar caps override (child wins) — the floor merge takes
754        // the stricter side across org vs. local separately.
755        if let Some(v) = layer.budgets.max_cost_usd_per_person_per_day {
756            resolved.budgets.max_cost_usd_per_person_per_day = Some(v);
757        }
758        if let Some(v) = layer.budgets.max_cost_usd_per_project_per_month {
759            resolved.budgets.max_cost_usd_per_project_per_month = Some(v);
760        }
761    }
762
763    // A resolved allowlist must not collide with accumulated denies.
764    if let Some(allow) = &resolved.allow_tools {
765        let overlap: Vec<String> = allow
766            .iter()
767            .filter(|t| resolved.deny_tools.contains(*t))
768            .cloned()
769            .collect();
770        if !overlap.is_empty() {
771            return Err(PolicyError::AllowDenyOverlap(overlap));
772        }
773    }
774    Ok(resolved)
775}
776
777/// Parse + validate + resolve in one step — the common CLI path.
778pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
779    resolve(&parse(toml_text)?)
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
787        PolicyPack {
788            name: name.to_string(),
789            version: "1.0.0".to_string(),
790            description: "test pack".to_string(),
791            extends: extends.map(str::to_string),
792            context: ContextRules::default(),
793            redaction: BTreeMap::new(),
794            filters: FilterRules::default(),
795            egress: EgressRules::default(),
796            routing: RoutingPolicyRules::default(),
797            budgets: BudgetRules::default(),
798        }
799    }
800
801    #[test]
802    fn parses_a_full_pack() {
803        let pack = parse(
804            r#"
805name = "acme-internal"
806version = "2.1.0"
807description = "ACME internal baseline"
808extends = "strict-redaction"
809
810[context]
811default_read_mode = "map"
812deny_tools = ["ctx_url_read"]
813max_context_tokens = 12000
814audit_retention_days = 365
815
816[redaction]
817employee_id = 'EMP-\d{6}'
818"#,
819        )
820        .expect("parses");
821        assert_eq!(pack.name, "acme-internal");
822        assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
823        assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
824        assert!(pack.redaction.contains_key("employee_id"));
825    }
826
827    #[test]
828    fn parses_gateway_governance_sections() {
829        // enterprise#25: [routing] + [budgets] ride inside the same signed pack.
830        let resolved = load(
831            r#"
832name = "acme-gateway"
833version = "1.0.0"
834description = "org gateway governance"
835
836[routing]
837allowed_models = ["claude-*", "gpt-4o-mini"]
838forbid_downgrade_for = ["prod"]
839
840[budgets]
841max_cost_usd_per_person_per_day = 50.0
842max_cost_usd_per_project_per_month = 20000.0
843"#,
844        )
845        .expect("resolves");
846        assert_eq!(
847            resolved.routing.allowed_models,
848            vec!["claude-*".to_string(), "gpt-4o-mini".to_string()]
849        );
850        assert_eq!(resolved.routing.forbid_downgrade_for, vec!["prod"]);
851        assert_eq!(resolved.budgets.max_cost_usd_per_person_per_day, Some(50.0));
852        assert_eq!(
853            resolved.budgets.max_cost_usd_per_project_per_month,
854            Some(20000.0)
855        );
856    }
857
858    #[test]
859    fn rejects_invalid_budget_and_empty_model_pattern() {
860        let neg = parse(
861            r#"
862name = "bad-budget"
863version = "1.0.0"
864description = "x"
865
866[budgets]
867max_cost_usd_per_person_per_day = -5.0
868"#,
869        );
870        assert!(matches!(neg, Err(PolicyError::InvalidBudget { .. })));
871
872        let empty = parse(
873            r#"
874name = "bad-pattern"
875version = "1.0.0"
876description = "x"
877
878[routing]
879allowed_models = ["claude-*", " "]
880"#,
881        );
882        assert_eq!(empty.unwrap_err(), PolicyError::EmptyModelPattern);
883    }
884
885    #[test]
886    fn unknown_keys_are_rejected() {
887        let err = parse(
888            r#"
889name = "typo"
890version = "1.0.0"
891description = "x"
892
893[context]
894alow_tools = ["ctx_read"]
895"#,
896        )
897        .unwrap_err();
898        assert!(matches!(err, PolicyError::Toml(_)), "{err}");
899    }
900
901    #[test]
902    fn validation_catches_each_field() {
903        let mut p = minimal("Bad Name", None);
904        assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
905
906        p = minimal("ok", None);
907        p.version = "1.0".into();
908        assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
909
910        p = minimal("ok", None);
911        p.description = "  ".into();
912        assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
913
914        p = minimal("ok", None);
915        p.context.default_read_mode = Some("lines:1-5".into());
916        assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
917
918        p = minimal("ok", None);
919        p.context.max_context_tokens = Some(0);
920        assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
921
922        p = minimal("ok", None);
923        p.redaction.insert("broken".into(), "(unclosed".into());
924        assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
925
926        p = minimal("ok", None);
927        p.context.allow_tools = Some(vec!["ctx_read".into()]);
928        p.context.deny_tools = vec!["ctx_read".into()];
929        assert!(matches!(
930            validate(&p),
931            Err(PolicyError::AllowDenyOverlap(_))
932        ));
933    }
934
935    #[test]
936    fn resolve_overrides_scalars_and_accumulates_denies() {
937        let mut child = minimal("child", Some("strict-redaction"));
938        child.context.default_read_mode = Some("signatures".into());
939        child.context.deny_tools = vec!["ctx_url_read".into(), "ctx_shell".into()];
940        child
941            .redaction
942            .insert("employee_id".into(), r"EMP-\d{4}".into());
943        let r = resolve(&child).expect("resolves");
944
945        // Scalar overridden by the child.
946        assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
947        // The child's denies apply.
948        assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
949        assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
950        // Redaction accumulated from the whole chain (baseline + strict + child).
951        assert!(r.redaction.contains_key("employee_id"));
952        assert!(r.redaction.contains_key("private_key"));
953        // Chain is base-most first and excludes the pack itself.
954        assert_eq!(r.chain, vec!["baseline", "strict-redaction"]);
955    }
956
957    #[test]
958    fn resolve_rejects_unknown_parent_and_cycle() {
959        let p = minimal("orphan", Some("no-such-pack"));
960        assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
961
962        // A former built-in that moved to a paid tier says so.
963        let p = minimal("regulated", Some("healthcare"));
964        let err = resolve(&p).expect_err("not bundled");
965        assert!(err.to_string().contains("LeanCTX | Hive"), "{err}");
966
967        // Self-reference is the minimal cycle reachable without registering
968        // custom packs (built-ins are acyclic by construction + test below).
969        let p = minimal("loop", Some("loop"));
970        assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
971    }
972
973    #[test]
974    fn child_redaction_overrides_same_named_parent_pattern() {
975        let mut child = minimal("child", Some("baseline"));
976        child
977            .redaction
978            .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
979        let r = resolve(&child).expect("resolves");
980        assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
981    }
982}