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 {
93 if !self.auto_apply_enabled || target_class != "memory" {
94 return false;
95 }
96 self.auto_apply.iter().any(|g| {
97 crate::manifest::analyzer_family(&g.analyzer) == family
98 && g.targets.iter().any(|t| t == target_class)
99 && severity <= g.max_severity
100 })
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
113 fn code_targets_never_auto_apply_even_when_granted() {
114 let p = Policy::from_json(
115 r#"{"auto_apply_enabled": true,
116 "auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
117 )
118 .unwrap();
119 assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
120 assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
121 assert!(
122 p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
123 "the same grant's memory leg still works — the exclusion is by class"
124 );
125 }
126
127 #[test]
128 fn default_policy_grants_nothing() {
129 let p = Policy::default();
130 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
131 assert!(!p.denies("loop.staleness"));
132 assert_eq!(p.telemetry, TelemetryMode::Aggregate);
133 }
134
135 #[test]
136 fn parses_and_grants() {
137 let p = Policy::from_json(
138 r#"{"auto_apply_enabled": true,
139 "auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
140 "deny": ["loop.staleness"],
141 "severity_floors": {"loop.contradiction_sweep": "high"}}"#,
142 )
143 .unwrap();
144 assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
145 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
146 assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
147 assert!(p.denies("loop.staleness"));
148 assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
149 }
150
151 #[test]
152 fn prompt_and_host_targets_never_granted() {
153 let p = Policy::from_json(
154 r#"{"auto_apply_enabled": true,
155 "auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
156 )
157 .unwrap();
158 assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
159 assert!(!p.grants_auto_apply("x", "host", Severity::Info));
160 }
161
162 #[test]
163 fn unknown_keys_rejected() {
164 assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
166 assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
167 }
168}