1pub 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
34const MAX_EXTENDS_DEPTH: usize = 8;
37
38pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct PolicyPack {
60 pub name: String,
62 pub version: String,
64 pub description: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub extends: Option<String>,
69 #[serde(default)]
71 pub context: ContextRules,
72 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75 pub redaction: BTreeMap<String, String>,
76 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
79 pub filters: FilterRules,
80 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
82 pub egress: EgressRules,
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct ContextRules {
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub default_read_mode: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub allow_tools: Option<Vec<String>>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub deny_tools: Vec<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub max_context_tokens: Option<u32>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub audit_retention_days: Option<u32>,
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct FilterRules {
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub pii: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub classification: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub injection: Option<String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub blocked_labels: Vec<String>,
128}
129
130impl FilterRules {
131 #[must_use]
133 pub fn is_empty(&self) -> bool {
134 self.pii.is_none()
135 && self.classification.is_none()
136 && self.injection.is_none()
137 && self.blocked_labels.is_empty()
138 }
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct EgressRules {
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub forbidden_patterns: Vec<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub block_secrets: Option<bool>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub max_writes_per_min: Option<u32>,
157}
158
159impl EgressRules {
160 #[must_use]
162 pub fn is_empty(&self) -> bool {
163 self.forbidden_patterns.is_empty()
164 && self.block_secrets.is_none()
165 && self.max_writes_per_min.is_none()
166 }
167}
168
169#[derive(Debug, Clone, Serialize)]
174pub struct ResolvedPolicy {
175 pub name: String,
176 pub version: String,
177 pub description: String,
178 pub chain: Vec<String>,
181 pub default_read_mode: Option<String>,
182 pub allow_tools: Option<Vec<String>>,
183 pub deny_tools: Vec<String>,
184 pub max_context_tokens: Option<u32>,
185 pub audit_retention_days: Option<u32>,
186 pub redaction: BTreeMap<String, String>,
187 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
189 pub filters: FilterRules,
190 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
192 pub egress: EgressRules,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum PolicyError {
201 Toml(String),
202 InvalidName(String),
203 InvalidVersion(String),
204 EmptyDescription,
205 UnknownReadMode(String),
206 BadRegex { pattern_name: String, error: String },
207 ZeroMaxTokens,
208 AllowDenyOverlap(Vec<String>),
209 UnknownParent(String),
210 ExtendsCycle(Vec<String>),
211 ExtendsTooDeep(usize),
212 UnknownFilterAction { field: String, value: String },
213}
214
215impl std::fmt::Display for PolicyError {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 match self {
218 PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
219 PolicyError::InvalidName(n) => write!(
220 f,
221 "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
222 ),
223 PolicyError::InvalidVersion(v) => {
224 write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
225 }
226 PolicyError::EmptyDescription => write!(f, "description must not be empty"),
227 PolicyError::UnknownReadMode(m) => write!(
228 f,
229 "unknown default_read_mode '{m}' (one of: {})",
230 KNOWN_READ_MODES.join(", ")
231 ),
232 PolicyError::BadRegex {
233 pattern_name,
234 error,
235 } => write!(
236 f,
237 "redaction pattern '{pattern_name}' is not a valid regex: {error}"
238 ),
239 PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
240 PolicyError::AllowDenyOverlap(tools) => write!(
241 f,
242 "tools listed in both allow_tools and deny_tools: {}",
243 tools.join(", ")
244 ),
245 PolicyError::UnknownParent(p) => write!(
246 f,
247 "extends '{p}' does not name a known pack (built-ins: {})",
248 builtin::names().join(", ")
249 ),
250 PolicyError::ExtendsCycle(chain) => {
251 write!(f, "extends cycle: {}", chain.join(" -> "))
252 }
253 PolicyError::ExtendsTooDeep(d) => write!(
254 f,
255 "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
256 ),
257 PolicyError::UnknownFilterAction { field, value } => write!(
258 f,
259 "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
260 ),
261 }
262 }
263}
264
265impl std::error::Error for PolicyError {}
266
267pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
272 let pack: PolicyPack =
273 toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
274 validate(&pack)?;
275 Ok(pack)
276}
277
278pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
281 let text = std::fs::read_to_string(path)
282 .map_err(|e| PolicyError::Toml(format!("{}: {e}", path.display())))?;
283 parse(&text)
284}
285
286pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
288 if pack.name.is_empty()
289 || !pack
290 .name
291 .bytes()
292 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
293 || pack.name.starts_with('-')
294 || pack.name.ends_with('-')
295 {
296 return Err(PolicyError::InvalidName(pack.name.clone()));
297 }
298 if !valid_semver(&pack.version) {
299 return Err(PolicyError::InvalidVersion(pack.version.clone()));
300 }
301 if pack.description.trim().is_empty() {
302 return Err(PolicyError::EmptyDescription);
303 }
304 if let Some(mode) = pack.context.default_read_mode.as_deref()
305 && !KNOWN_READ_MODES.contains(&mode)
306 {
307 return Err(PolicyError::UnknownReadMode(mode.to_string()));
308 }
309 if let Some(max) = pack.context.max_context_tokens
310 && max == 0
311 {
312 return Err(PolicyError::ZeroMaxTokens);
313 }
314 if let Some(allow) = &pack.context.allow_tools {
315 let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
316 let overlap: Vec<String> = allow
317 .iter()
318 .filter(|t| deny.contains(t.as_str()))
319 .cloned()
320 .collect();
321 if !overlap.is_empty() {
322 return Err(PolicyError::AllowDenyOverlap(overlap));
323 }
324 }
325 for (name, pattern) in &pack.redaction {
326 if let Err(e) = regex::Regex::new(pattern) {
327 return Err(PolicyError::BadRegex {
328 pattern_name: name.clone(),
329 error: e.to_string(),
330 });
331 }
332 }
333 validate_filter_action("pii", pack.filters.pii.as_deref())?;
334 validate_filter_action("classification", pack.filters.classification.as_deref())?;
335 validate_filter_action("injection", pack.filters.injection.as_deref())?;
336 for pattern in &pack.egress.forbidden_patterns {
337 if let Err(e) = regex::Regex::new(pattern) {
338 return Err(PolicyError::BadRegex {
339 pattern_name: format!("egress.forbidden_patterns: {pattern}"),
340 error: e.to_string(),
341 });
342 }
343 }
344 Ok(())
345}
346
347fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
349 if let Some(v) = value
350 && crate::core::input_filters::FilterAction::parse(v).is_none()
351 {
352 return Err(PolicyError::UnknownFilterAction {
353 field: field.to_string(),
354 value: v.to_string(),
355 });
356 }
357 Ok(())
358}
359
360fn valid_semver(v: &str) -> bool {
362 let parts: Vec<&str> = v.split('.').collect();
363 parts.len() == 3
364 && parts
365 .iter()
366 .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
367}
368
369pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
374 let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
376 let mut seen: Vec<String> = vec![pack.name.clone()];
377 let mut next_parent = pack.extends.clone();
378 while let Some(parent_name) = next_parent.take() {
379 if seen.contains(&parent_name) {
380 seen.push(parent_name);
381 return Err(PolicyError::ExtendsCycle(seen));
382 }
383 if lineage.len() >= MAX_EXTENDS_DEPTH {
384 return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
385 }
386 let parent =
387 builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
388 seen.push(parent_name);
389 next_parent.clone_from(&parent.extends);
390 lineage.push(parent);
391 }
392
393 let mut resolved = ResolvedPolicy {
396 name: pack.name.clone(),
397 version: pack.version.clone(),
398 description: pack.description.clone(),
399 chain: seen.iter().skip(1).rev().cloned().collect(),
400 default_read_mode: None,
401 allow_tools: None,
402 deny_tools: Vec::new(),
403 max_context_tokens: None,
404 audit_retention_days: None,
405 redaction: BTreeMap::new(),
406 filters: FilterRules::default(),
407 egress: EgressRules::default(),
408 };
409 for layer in lineage.iter().rev() {
410 if let Some(mode) = &layer.context.default_read_mode {
411 resolved.default_read_mode = Some(mode.clone());
412 }
413 if let Some(allow) = &layer.context.allow_tools {
414 resolved.allow_tools = Some(allow.clone());
415 }
416 for tool in &layer.context.deny_tools {
417 if !resolved.deny_tools.contains(tool) {
418 resolved.deny_tools.push(tool.clone());
419 }
420 }
421 if let Some(max) = layer.context.max_context_tokens {
422 resolved.max_context_tokens = Some(max);
423 }
424 if let Some(days) = layer.context.audit_retention_days {
425 resolved.audit_retention_days = Some(days);
426 }
427 for (name, pattern) in &layer.redaction {
428 resolved.redaction.insert(name.clone(), pattern.clone());
429 }
430 if let Some(v) = &layer.filters.pii {
432 resolved.filters.pii = Some(v.clone());
433 }
434 if let Some(v) = &layer.filters.classification {
435 resolved.filters.classification = Some(v.clone());
436 }
437 if let Some(v) = &layer.filters.injection {
438 resolved.filters.injection = Some(v.clone());
439 }
440 for label in &layer.filters.blocked_labels {
441 if !resolved.filters.blocked_labels.contains(label) {
442 resolved.filters.blocked_labels.push(label.clone());
443 }
444 }
445 for pattern in &layer.egress.forbidden_patterns {
447 if !resolved.egress.forbidden_patterns.contains(pattern) {
448 resolved.egress.forbidden_patterns.push(pattern.clone());
449 }
450 }
451 if let Some(v) = layer.egress.block_secrets {
452 resolved.egress.block_secrets = Some(v);
453 }
454 if let Some(v) = layer.egress.max_writes_per_min {
455 resolved.egress.max_writes_per_min = Some(v);
456 }
457 }
458
459 if let Some(allow) = &resolved.allow_tools {
461 let overlap: Vec<String> = allow
462 .iter()
463 .filter(|t| resolved.deny_tools.contains(*t))
464 .cloned()
465 .collect();
466 if !overlap.is_empty() {
467 return Err(PolicyError::AllowDenyOverlap(overlap));
468 }
469 }
470 Ok(resolved)
471}
472
473pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
475 resolve(&parse(toml_text)?)
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
483 PolicyPack {
484 name: name.to_string(),
485 version: "1.0.0".to_string(),
486 description: "test pack".to_string(),
487 extends: extends.map(str::to_string),
488 context: ContextRules::default(),
489 redaction: BTreeMap::new(),
490 filters: FilterRules::default(),
491 egress: EgressRules::default(),
492 }
493 }
494
495 #[test]
496 fn parses_a_full_pack() {
497 let pack = parse(
498 r#"
499name = "acme-internal"
500version = "2.1.0"
501description = "ACME internal baseline"
502extends = "strict-redaction"
503
504[context]
505default_read_mode = "map"
506deny_tools = ["ctx_url_read"]
507max_context_tokens = 12000
508audit_retention_days = 365
509
510[redaction]
511employee_id = 'EMP-\d{6}'
512"#,
513 )
514 .expect("parses");
515 assert_eq!(pack.name, "acme-internal");
516 assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
517 assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
518 assert!(pack.redaction.contains_key("employee_id"));
519 }
520
521 #[test]
522 fn unknown_keys_are_rejected() {
523 let err = parse(
524 r#"
525name = "typo"
526version = "1.0.0"
527description = "x"
528
529[context]
530alow_tools = ["ctx_read"]
531"#,
532 )
533 .unwrap_err();
534 assert!(matches!(err, PolicyError::Toml(_)), "{err}");
535 }
536
537 #[test]
538 fn validation_catches_each_field() {
539 let mut p = minimal("Bad Name", None);
540 assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
541
542 p = minimal("ok", None);
543 p.version = "1.0".into();
544 assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
545
546 p = minimal("ok", None);
547 p.description = " ".into();
548 assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
549
550 p = minimal("ok", None);
551 p.context.default_read_mode = Some("lines:1-5".into());
552 assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
553
554 p = minimal("ok", None);
555 p.context.max_context_tokens = Some(0);
556 assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
557
558 p = minimal("ok", None);
559 p.redaction.insert("broken".into(), "(unclosed".into());
560 assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
561
562 p = minimal("ok", None);
563 p.context.allow_tools = Some(vec!["ctx_read".into()]);
564 p.context.deny_tools = vec!["ctx_read".into()];
565 assert!(matches!(
566 validate(&p),
567 Err(PolicyError::AllowDenyOverlap(_))
568 ));
569 }
570
571 #[test]
572 fn resolve_overrides_scalars_and_accumulates_denies() {
573 let mut child = minimal("child", Some("finance-eu"));
574 child.context.default_read_mode = Some("signatures".into());
575 child.context.deny_tools = vec!["ctx_shell".into()];
576 let r = resolve(&child).expect("resolves");
577
578 assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
580 assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
582 assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
583 assert!(r.redaction.contains_key("iban"));
585 assert!(r.redaction.contains_key("private_key"));
586 assert_eq!(r.chain, vec!["baseline", "strict-redaction", "finance-eu"]);
588 }
589
590 #[test]
591 fn resolve_rejects_unknown_parent_and_cycle() {
592 let p = minimal("orphan", Some("no-such-pack"));
593 assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
594
595 let p = minimal("loop", Some("loop"));
598 assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
599 }
600
601 #[test]
602 fn child_redaction_overrides_same_named_parent_pattern() {
603 let mut child = minimal("child", Some("baseline"));
604 child
605 .redaction
606 .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
607 let r = resolve(&child).expect("resolves");
608 assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
609 }
610}