1use crate::error::{Error, Result};
13use crate::model::Severity;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum TelemetryMode {
21 Off,
22 #[default]
23 Aggregate,
24 Full,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct AutoApplyGrant {
32 pub analyzer: String,
35 pub targets: Vec<String>,
38 pub max_severity: Severity,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Policy {
46 #[serde(default)]
49 pub auto_apply_enabled: bool,
50 #[serde(default)]
52 pub auto_apply: Vec<AutoApplyGrant>,
53 #[serde(default)]
55 pub deny: Vec<String>,
56 #[serde(default)]
59 pub severity_floors: BTreeMap<String, Severity>,
60 #[serde(default)]
61 pub telemetry: TelemetryMode,
62}
63
64impl Policy {
65 pub fn from_json(s: &str) -> Result<Self> {
67 serde_json::from_str(s).map_err(|e| Error::InvalidProposal(format!("policy: {e}")))
68 }
69
70 pub fn denies(&self, family: &str) -> bool {
72 self.deny.iter().any(|d| crate::manifest::analyzer_family(d) == family)
73 }
74
75 pub fn severity_floor(&self, family: &str) -> Option<Severity> {
77 self.severity_floors
78 .iter()
79 .find(|(k, _)| crate::manifest::analyzer_family(k) == family)
80 .map(|(_, v)| *v)
81 }
82
83 pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
86 if !self.auto_apply_enabled || !matches!(target_class, "memory" | "query") {
87 return false;
88 }
89 self.auto_apply.iter().any(|g| {
90 crate::manifest::analyzer_family(&g.analyzer) == family
91 && g.targets.iter().any(|t| t == target_class)
92 && severity <= g.max_severity
93 })
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
106 fn code_targets_never_auto_apply_even_when_granted() {
107 let p = Policy::from_json(
108 r#"{"auto_apply_enabled": true,
109 "auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
110 )
111 .unwrap();
112 assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
113 assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
114 assert!(
115 p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
116 "the same grant's memory leg still works — the exclusion is by class"
117 );
118 }
119
120 #[test]
121 fn default_policy_grants_nothing() {
122 let p = Policy::default();
123 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
124 assert!(!p.denies("loop.staleness"));
125 assert_eq!(p.telemetry, TelemetryMode::Aggregate);
126 }
127
128 #[test]
129 fn parses_and_grants() {
130 let p = Policy::from_json(
131 r#"{"auto_apply_enabled": true,
132 "auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
133 "deny": ["loop.staleness"],
134 "severity_floors": {"loop.contradiction_sweep": "high"}}"#,
135 )
136 .unwrap();
137 assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
138 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
139 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
140 assert!(p.denies("loop.staleness"));
141 assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
142 }
143
144 #[test]
145 fn prompt_and_host_targets_never_granted() {
146 let p = Policy::from_json(
147 r#"{"auto_apply_enabled": true,
148 "auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
149 )
150 .unwrap();
151 assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
152 assert!(!p.grants_auto_apply("x", "host", Severity::Info));
153 }
154
155 #[test]
156 fn unknown_keys_rejected() {
157 assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
159 assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
160 }
161}