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;
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30
31/// Maximum `extends` chain depth (defense against runaway chains; built-ins
32/// use at most 2).
33const MAX_EXTENDS_DEPTH: usize = 8;
34
35/// Read modes a pack may pin as `default_read_mode` — the documented
36/// `ctx_read` mode vocabulary (range reads like `lines:N-M` are call-site
37/// specific and make no sense as a policy default).
38pub const KNOWN_READ_MODES: &[&str] = &[
39    "auto",
40    "full",
41    "map",
42    "signatures",
43    "diff",
44    "task",
45    "reference",
46    "aggressive",
47    "entropy",
48];
49
50// ── Wire format ──────────────────────────────────────────────────────────────
51
52/// One policy pack as written in TOML. Unknown keys are rejected so a typo
53/// (`alow_tools`) fails validation instead of silently weakening a policy.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct PolicyPack {
57    /// Stable identifier: lowercase, digits and hyphens (`finance-eu`).
58    pub name: String,
59    /// Semantic version of the pack itself (`1.0.0`).
60    pub version: String,
61    /// One-line human description.
62    pub description: String,
63    /// Optional parent pack (built-in name) this pack inherits from.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub extends: Option<String>,
66    /// Context-governance expectations.
67    #[serde(default)]
68    pub context: ContextRules,
69    /// Named redaction patterns: name → regex (matched against content before
70    /// it enters the model context).
71    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72    pub redaction: BTreeMap<String, String>,
73}
74
75/// The `[context]` section of a pack. All fields optional — only what a pack
76/// states is constrained; everything else stays at engine defaults.
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct ContextRules {
80    /// Default `ctx_read` mode the policy expects (see [`KNOWN_READ_MODES`]).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub default_read_mode: Option<String>,
83    /// Allowlist of tool names; when set, only these may be called.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub allow_tools: Option<Vec<String>>,
86    /// Denylist of tool names; always additive down the `extends` chain.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub deny_tools: Vec<String>,
89    /// Upper bound on tokens a single context assembly may spend.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub max_context_tokens: Option<u32>,
92    /// Audit-retention expectation in days (governance intent; the hosted
93    /// plane enforces its own plan window — see org-audit-log-v1).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub audit_retention_days: Option<u32>,
96}
97
98// ── Resolved view ────────────────────────────────────────────────────────────
99
100/// A pack with its full `extends` chain folded in — what enforcement and
101/// `policy show` consume.
102#[derive(Debug, Clone, Serialize)]
103pub struct ResolvedPolicy {
104    pub name: String,
105    pub version: String,
106    pub description: String,
107    /// Inheritance chain, base-most first (`["baseline", "strict-redaction"]`
108    /// for a pack extending `strict-redaction`). Empty for root packs.
109    pub chain: Vec<String>,
110    pub default_read_mode: Option<String>,
111    pub allow_tools: Option<Vec<String>>,
112    pub deny_tools: Vec<String>,
113    pub max_context_tokens: Option<u32>,
114    pub audit_retention_days: Option<u32>,
115    pub redaction: BTreeMap<String, String>,
116}
117
118// ── Errors ───────────────────────────────────────────────────────────────────
119
120/// Why a pack failed to parse, validate or resolve. Rendered verbatim by the
121/// CLI, so every variant names the offending field and value.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum PolicyError {
124    Toml(String),
125    InvalidName(String),
126    InvalidVersion(String),
127    EmptyDescription,
128    UnknownReadMode(String),
129    BadRegex { pattern_name: String, error: String },
130    ZeroMaxTokens,
131    AllowDenyOverlap(Vec<String>),
132    UnknownParent(String),
133    ExtendsCycle(Vec<String>),
134    ExtendsTooDeep(usize),
135}
136
137impl std::fmt::Display for PolicyError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
141            PolicyError::InvalidName(n) => write!(
142                f,
143                "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
144            ),
145            PolicyError::InvalidVersion(v) => {
146                write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
147            }
148            PolicyError::EmptyDescription => write!(f, "description must not be empty"),
149            PolicyError::UnknownReadMode(m) => write!(
150                f,
151                "unknown default_read_mode '{m}' (one of: {})",
152                KNOWN_READ_MODES.join(", ")
153            ),
154            PolicyError::BadRegex {
155                pattern_name,
156                error,
157            } => write!(
158                f,
159                "redaction pattern '{pattern_name}' is not a valid regex: {error}"
160            ),
161            PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
162            PolicyError::AllowDenyOverlap(tools) => write!(
163                f,
164                "tools listed in both allow_tools and deny_tools: {}",
165                tools.join(", ")
166            ),
167            PolicyError::UnknownParent(p) => write!(
168                f,
169                "extends '{p}' does not name a known pack (built-ins: {})",
170                builtin::names().join(", ")
171            ),
172            PolicyError::ExtendsCycle(chain) => {
173                write!(f, "extends cycle: {}", chain.join(" -> "))
174            }
175            PolicyError::ExtendsTooDeep(d) => write!(
176                f,
177                "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
178            ),
179        }
180    }
181}
182
183impl std::error::Error for PolicyError {}
184
185// ── Parse + validate ─────────────────────────────────────────────────────────
186
187/// Parse one pack from TOML text (no I/O) and validate it standalone.
188/// `extends` is checked against the built-ins during [`resolve`].
189pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
190    let pack: PolicyPack =
191        toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
192    validate(&pack)?;
193    Ok(pack)
194}
195
196/// Parse a pack from a file path. Read errors surface as [`PolicyError::Toml`]
197/// with the OS message — the CLI shows them verbatim.
198pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
199    let text = std::fs::read_to_string(path)
200        .map_err(|e| PolicyError::Toml(format!("{}: {e}", path.display())))?;
201    parse(&text)
202}
203
204/// Field-level validation of a single (unresolved) pack.
205pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
206    if pack.name.is_empty()
207        || !pack
208            .name
209            .bytes()
210            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
211        || pack.name.starts_with('-')
212        || pack.name.ends_with('-')
213    {
214        return Err(PolicyError::InvalidName(pack.name.clone()));
215    }
216    if !valid_semver(&pack.version) {
217        return Err(PolicyError::InvalidVersion(pack.version.clone()));
218    }
219    if pack.description.trim().is_empty() {
220        return Err(PolicyError::EmptyDescription);
221    }
222    if let Some(mode) = pack.context.default_read_mode.as_deref() {
223        if !KNOWN_READ_MODES.contains(&mode) {
224            return Err(PolicyError::UnknownReadMode(mode.to_string()));
225        }
226    }
227    if let Some(max) = pack.context.max_context_tokens {
228        if max == 0 {
229            return Err(PolicyError::ZeroMaxTokens);
230        }
231    }
232    if let Some(allow) = &pack.context.allow_tools {
233        let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
234        let overlap: Vec<String> = allow
235            .iter()
236            .filter(|t| deny.contains(t.as_str()))
237            .cloned()
238            .collect();
239        if !overlap.is_empty() {
240            return Err(PolicyError::AllowDenyOverlap(overlap));
241        }
242    }
243    for (name, pattern) in &pack.redaction {
244        if let Err(e) = regex::Regex::new(pattern) {
245            return Err(PolicyError::BadRegex {
246                pattern_name: name.clone(),
247                error: e.to_string(),
248            });
249        }
250    }
251    Ok(())
252}
253
254/// `MAJOR.MINOR.PATCH`, digits only — packs don't need pre-release tags.
255fn valid_semver(v: &str) -> bool {
256    let parts: Vec<&str> = v.split('.').collect();
257    parts.len() == 3
258        && parts
259            .iter()
260            .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
261}
262
263// ── Resolve (extends) ────────────────────────────────────────────────────────
264
265/// Fold a pack's `extends` chain (against the built-ins) into one
266/// [`ResolvedPolicy`]. See the module docs for the inheritance semantics.
267pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
268    // Walk to the root, collecting the chain (child first).
269    let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
270    let mut seen: Vec<String> = vec![pack.name.clone()];
271    let mut next_parent = pack.extends.clone();
272    while let Some(parent_name) = next_parent.take() {
273        if seen.contains(&parent_name) {
274            seen.push(parent_name);
275            return Err(PolicyError::ExtendsCycle(seen));
276        }
277        if lineage.len() >= MAX_EXTENDS_DEPTH {
278            return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
279        }
280        let parent =
281            builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
282        seen.push(parent_name);
283        next_parent.clone_from(&parent.extends);
284        lineage.push(parent);
285    }
286
287    // Fold base-most first so children override scalars and accumulate
288    // restrictions on top.
289    let mut resolved = ResolvedPolicy {
290        name: pack.name.clone(),
291        version: pack.version.clone(),
292        description: pack.description.clone(),
293        chain: seen.iter().skip(1).rev().cloned().collect(),
294        default_read_mode: None,
295        allow_tools: None,
296        deny_tools: Vec::new(),
297        max_context_tokens: None,
298        audit_retention_days: None,
299        redaction: BTreeMap::new(),
300    };
301    for layer in lineage.iter().rev() {
302        if let Some(mode) = &layer.context.default_read_mode {
303            resolved.default_read_mode = Some(mode.clone());
304        }
305        if let Some(allow) = &layer.context.allow_tools {
306            resolved.allow_tools = Some(allow.clone());
307        }
308        for tool in &layer.context.deny_tools {
309            if !resolved.deny_tools.contains(tool) {
310                resolved.deny_tools.push(tool.clone());
311            }
312        }
313        if let Some(max) = layer.context.max_context_tokens {
314            resolved.max_context_tokens = Some(max);
315        }
316        if let Some(days) = layer.context.audit_retention_days {
317            resolved.audit_retention_days = Some(days);
318        }
319        for (name, pattern) in &layer.redaction {
320            resolved.redaction.insert(name.clone(), pattern.clone());
321        }
322    }
323
324    // A resolved allowlist must not collide with accumulated denies.
325    if let Some(allow) = &resolved.allow_tools {
326        let overlap: Vec<String> = allow
327            .iter()
328            .filter(|t| resolved.deny_tools.contains(*t))
329            .cloned()
330            .collect();
331        if !overlap.is_empty() {
332            return Err(PolicyError::AllowDenyOverlap(overlap));
333        }
334    }
335    Ok(resolved)
336}
337
338/// Parse + validate + resolve in one step — the common CLI path.
339pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
340    resolve(&parse(toml_text)?)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
348        PolicyPack {
349            name: name.to_string(),
350            version: "1.0.0".to_string(),
351            description: "test pack".to_string(),
352            extends: extends.map(str::to_string),
353            context: ContextRules::default(),
354            redaction: BTreeMap::new(),
355        }
356    }
357
358    #[test]
359    fn parses_a_full_pack() {
360        let pack = parse(
361            r#"
362name = "acme-internal"
363version = "2.1.0"
364description = "ACME internal baseline"
365extends = "strict-redaction"
366
367[context]
368default_read_mode = "map"
369deny_tools = ["ctx_url_read"]
370max_context_tokens = 12000
371audit_retention_days = 365
372
373[redaction]
374employee_id = 'EMP-\d{6}'
375"#,
376        )
377        .expect("parses");
378        assert_eq!(pack.name, "acme-internal");
379        assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
380        assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
381        assert!(pack.redaction.contains_key("employee_id"));
382    }
383
384    #[test]
385    fn unknown_keys_are_rejected() {
386        let err = parse(
387            r#"
388name = "typo"
389version = "1.0.0"
390description = "x"
391
392[context]
393alow_tools = ["ctx_read"]
394"#,
395        )
396        .unwrap_err();
397        assert!(matches!(err, PolicyError::Toml(_)), "{err}");
398    }
399
400    #[test]
401    fn validation_catches_each_field() {
402        let mut p = minimal("Bad Name", None);
403        assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
404
405        p = minimal("ok", None);
406        p.version = "1.0".into();
407        assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
408
409        p = minimal("ok", None);
410        p.description = "  ".into();
411        assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
412
413        p = minimal("ok", None);
414        p.context.default_read_mode = Some("lines:1-5".into());
415        assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
416
417        p = minimal("ok", None);
418        p.context.max_context_tokens = Some(0);
419        assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
420
421        p = minimal("ok", None);
422        p.redaction.insert("broken".into(), "(unclosed".into());
423        assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
424
425        p = minimal("ok", None);
426        p.context.allow_tools = Some(vec!["ctx_read".into()]);
427        p.context.deny_tools = vec!["ctx_read".into()];
428        assert!(matches!(
429            validate(&p),
430            Err(PolicyError::AllowDenyOverlap(_))
431        ));
432    }
433
434    #[test]
435    fn resolve_overrides_scalars_and_accumulates_denies() {
436        let mut child = minimal("child", Some("finance-eu"));
437        child.context.default_read_mode = Some("signatures".into());
438        child.context.deny_tools = vec!["ctx_shell".into()];
439        let r = resolve(&child).expect("resolves");
440
441        // Scalar overridden by the child.
442        assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
443        // finance-eu's denies survive; the child's add on top.
444        assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
445        assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
446        // Redaction accumulated from the whole chain (baseline + strict + finance).
447        assert!(r.redaction.contains_key("iban"));
448        assert!(r.redaction.contains_key("private_key"));
449        // Chain is base-most first and excludes the pack itself.
450        assert_eq!(r.chain, vec!["baseline", "strict-redaction", "finance-eu"]);
451    }
452
453    #[test]
454    fn resolve_rejects_unknown_parent_and_cycle() {
455        let p = minimal("orphan", Some("no-such-pack"));
456        assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
457
458        // Self-reference is the minimal cycle reachable without registering
459        // custom packs (built-ins are acyclic by construction + test below).
460        let p = minimal("loop", Some("loop"));
461        assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
462    }
463
464    #[test]
465    fn child_redaction_overrides_same_named_parent_pattern() {
466        let mut child = minimal("child", Some("baseline"));
467        child
468            .redaction
469            .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
470        let r = resolve(&child).expect("resolves");
471        assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
472    }
473}