pub mod builtin;
pub mod coverage;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
const MAX_EXTENDS_DEPTH: usize = 8;
pub const KNOWN_READ_MODES: &[&str] = &[
"auto",
"full",
"map",
"signatures",
"diff",
"task",
"reference",
"aggressive",
"entropy",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyPack {
pub name: String,
pub version: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extends: Option<String>,
#[serde(default)]
pub context: ContextRules,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub redaction: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContextRules {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_read_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_retention_days: Option<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedPolicy {
pub name: String,
pub version: String,
pub description: String,
pub chain: Vec<String>,
pub default_read_mode: Option<String>,
pub allow_tools: Option<Vec<String>>,
pub deny_tools: Vec<String>,
pub max_context_tokens: Option<u32>,
pub audit_retention_days: Option<u32>,
pub redaction: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyError {
Toml(String),
InvalidName(String),
InvalidVersion(String),
EmptyDescription,
UnknownReadMode(String),
BadRegex { pattern_name: String, error: String },
ZeroMaxTokens,
AllowDenyOverlap(Vec<String>),
UnknownParent(String),
ExtendsCycle(Vec<String>),
ExtendsTooDeep(usize),
}
impl std::fmt::Display for PolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
PolicyError::InvalidName(n) => write!(
f,
"invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
),
PolicyError::InvalidVersion(v) => {
write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
}
PolicyError::EmptyDescription => write!(f, "description must not be empty"),
PolicyError::UnknownReadMode(m) => write!(
f,
"unknown default_read_mode '{m}' (one of: {})",
KNOWN_READ_MODES.join(", ")
),
PolicyError::BadRegex {
pattern_name,
error,
} => write!(
f,
"redaction pattern '{pattern_name}' is not a valid regex: {error}"
),
PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
PolicyError::AllowDenyOverlap(tools) => write!(
f,
"tools listed in both allow_tools and deny_tools: {}",
tools.join(", ")
),
PolicyError::UnknownParent(p) => write!(
f,
"extends '{p}' does not name a known pack (built-ins: {})",
builtin::names().join(", ")
),
PolicyError::ExtendsCycle(chain) => {
write!(f, "extends cycle: {}", chain.join(" -> "))
}
PolicyError::ExtendsTooDeep(d) => write!(
f,
"extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
),
}
}
}
impl std::error::Error for PolicyError {}
pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
let pack: PolicyPack =
toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
validate(&pack)?;
Ok(pack)
}
pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
let text = std::fs::read_to_string(path)
.map_err(|e| PolicyError::Toml(format!("{}: {e}", path.display())))?;
parse(&text)
}
pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
if pack.name.is_empty()
|| !pack
.name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|| pack.name.starts_with('-')
|| pack.name.ends_with('-')
{
return Err(PolicyError::InvalidName(pack.name.clone()));
}
if !valid_semver(&pack.version) {
return Err(PolicyError::InvalidVersion(pack.version.clone()));
}
if pack.description.trim().is_empty() {
return Err(PolicyError::EmptyDescription);
}
if let Some(mode) = pack.context.default_read_mode.as_deref() {
if !KNOWN_READ_MODES.contains(&mode) {
return Err(PolicyError::UnknownReadMode(mode.to_string()));
}
}
if let Some(max) = pack.context.max_context_tokens {
if max == 0 {
return Err(PolicyError::ZeroMaxTokens);
}
}
if let Some(allow) = &pack.context.allow_tools {
let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
let overlap: Vec<String> = allow
.iter()
.filter(|t| deny.contains(t.as_str()))
.cloned()
.collect();
if !overlap.is_empty() {
return Err(PolicyError::AllowDenyOverlap(overlap));
}
}
for (name, pattern) in &pack.redaction {
if let Err(e) = regex::Regex::new(pattern) {
return Err(PolicyError::BadRegex {
pattern_name: name.clone(),
error: e.to_string(),
});
}
}
Ok(())
}
fn valid_semver(v: &str) -> bool {
let parts: Vec<&str> = v.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
}
pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
let mut seen: Vec<String> = vec![pack.name.clone()];
let mut next_parent = pack.extends.clone();
while let Some(parent_name) = next_parent.take() {
if seen.contains(&parent_name) {
seen.push(parent_name);
return Err(PolicyError::ExtendsCycle(seen));
}
if lineage.len() >= MAX_EXTENDS_DEPTH {
return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
}
let parent =
builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
seen.push(parent_name);
next_parent.clone_from(&parent.extends);
lineage.push(parent);
}
let mut resolved = ResolvedPolicy {
name: pack.name.clone(),
version: pack.version.clone(),
description: pack.description.clone(),
chain: seen.iter().skip(1).rev().cloned().collect(),
default_read_mode: None,
allow_tools: None,
deny_tools: Vec::new(),
max_context_tokens: None,
audit_retention_days: None,
redaction: BTreeMap::new(),
};
for layer in lineage.iter().rev() {
if let Some(mode) = &layer.context.default_read_mode {
resolved.default_read_mode = Some(mode.clone());
}
if let Some(allow) = &layer.context.allow_tools {
resolved.allow_tools = Some(allow.clone());
}
for tool in &layer.context.deny_tools {
if !resolved.deny_tools.contains(tool) {
resolved.deny_tools.push(tool.clone());
}
}
if let Some(max) = layer.context.max_context_tokens {
resolved.max_context_tokens = Some(max);
}
if let Some(days) = layer.context.audit_retention_days {
resolved.audit_retention_days = Some(days);
}
for (name, pattern) in &layer.redaction {
resolved.redaction.insert(name.clone(), pattern.clone());
}
}
if let Some(allow) = &resolved.allow_tools {
let overlap: Vec<String> = allow
.iter()
.filter(|t| resolved.deny_tools.contains(*t))
.cloned()
.collect();
if !overlap.is_empty() {
return Err(PolicyError::AllowDenyOverlap(overlap));
}
}
Ok(resolved)
}
pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
resolve(&parse(toml_text)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
PolicyPack {
name: name.to_string(),
version: "1.0.0".to_string(),
description: "test pack".to_string(),
extends: extends.map(str::to_string),
context: ContextRules::default(),
redaction: BTreeMap::new(),
}
}
#[test]
fn parses_a_full_pack() {
let pack = parse(
r#"
name = "acme-internal"
version = "2.1.0"
description = "ACME internal baseline"
extends = "strict-redaction"
[context]
default_read_mode = "map"
deny_tools = ["ctx_url_read"]
max_context_tokens = 12000
audit_retention_days = 365
[redaction]
employee_id = 'EMP-\d{6}'
"#,
)
.expect("parses");
assert_eq!(pack.name, "acme-internal");
assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
assert!(pack.redaction.contains_key("employee_id"));
}
#[test]
fn unknown_keys_are_rejected() {
let err = parse(
r#"
name = "typo"
version = "1.0.0"
description = "x"
[context]
alow_tools = ["ctx_read"]
"#,
)
.unwrap_err();
assert!(matches!(err, PolicyError::Toml(_)), "{err}");
}
#[test]
fn validation_catches_each_field() {
let mut p = minimal("Bad Name", None);
assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
p = minimal("ok", None);
p.version = "1.0".into();
assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
p = minimal("ok", None);
p.description = " ".into();
assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
p = minimal("ok", None);
p.context.default_read_mode = Some("lines:1-5".into());
assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
p = minimal("ok", None);
p.context.max_context_tokens = Some(0);
assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
p = minimal("ok", None);
p.redaction.insert("broken".into(), "(unclosed".into());
assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
p = minimal("ok", None);
p.context.allow_tools = Some(vec!["ctx_read".into()]);
p.context.deny_tools = vec!["ctx_read".into()];
assert!(matches!(
validate(&p),
Err(PolicyError::AllowDenyOverlap(_))
));
}
#[test]
fn resolve_overrides_scalars_and_accumulates_denies() {
let mut child = minimal("child", Some("finance-eu"));
child.context.default_read_mode = Some("signatures".into());
child.context.deny_tools = vec!["ctx_shell".into()];
let r = resolve(&child).expect("resolves");
assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
assert!(r.redaction.contains_key("iban"));
assert!(r.redaction.contains_key("private_key"));
assert_eq!(r.chain, vec!["baseline", "strict-redaction", "finance-eu"]);
}
#[test]
fn resolve_rejects_unknown_parent_and_cycle() {
let p = minimal("orphan", Some("no-such-pack"));
assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
let p = minimal("loop", Some("loop"));
assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
}
#[test]
fn child_redaction_overrides_same_named_parent_pattern() {
let mut child = minimal("child", Some("baseline"));
child
.redaction
.insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
let r = resolve(&child).expect("resolves");
assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
}
}