1pub mod builtin;
24pub mod coverage;
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30
31const MAX_EXTENDS_DEPTH: usize = 8;
34
35pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct PolicyPack {
57 pub name: String,
59 pub version: String,
61 pub description: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub extends: Option<String>,
66 #[serde(default)]
68 pub context: ContextRules,
69 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub redaction: BTreeMap<String, String>,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct ContextRules {
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub default_read_mode: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub allow_tools: Option<Vec<String>>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub deny_tools: Vec<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub max_context_tokens: Option<u32>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub audit_retention_days: Option<u32>,
96}
97
98#[derive(Debug, Clone, Serialize)]
103pub struct ResolvedPolicy {
104 pub name: String,
105 pub version: String,
106 pub description: String,
107 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#[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
185pub 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
196pub 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
204pub 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
254fn 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
263pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
268 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 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 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
338pub 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 assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
443 assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
445 assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
446 assert!(r.redaction.contains_key("iban"));
448 assert!(r.redaction.contains_key("private_key"));
449 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 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}