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