1use std::collections::BTreeSet;
10
11use aios_spec::{
12 ActionType, ActionUrgency, AuthorizedAction, CapabilityLevel, DenialReason, Intent,
13 IntentBatch, RiskLevel, SanitizedEventType, StructuredContext,
14};
15use tracing::debug;
16
17#[derive(Debug, Clone)]
19pub struct PolicyDecision {
20 pub intent_id: String,
22 pub approved: bool,
24 pub rejection_reason: Option<DenialReason>,
26 pub action_denials: Vec<DenialReason>,
29 pub approved_actions: Vec<AuthorizedAction>,
31}
32
33#[derive(Debug, Clone)]
35pub struct PolicyConfig {
36 pub max_auto_risk: RiskLevel,
38 pub blocked_actions: Vec<String>,
40 pub max_actions_per_batch: usize,
42 pub min_confidence: f32,
44}
45
46impl Default for PolicyConfig {
47 fn default() -> Self {
48 Self {
49 max_auto_risk: RiskLevel::Low,
50 blocked_actions: vec![],
51 max_actions_per_batch: 5,
52 min_confidence: 0.3,
53 }
54 }
55}
56
57pub struct PolicyEngine {
59 config: PolicyConfig,
60}
61
62impl PolicyEngine {
63 pub fn new(config: PolicyConfig) -> Self {
64 Self { config }
65 }
66
67 pub fn evaluate_batch(&self, batch: &IntentBatch) -> Vec<PolicyDecision> {
72 batch
73 .intents
74 .iter()
75 .map(|intent| self.evaluate_intent(intent, batch.generated_at_ms, None, None))
76 .collect()
77 }
78
79 pub fn evaluate_batch_with_capability(
81 &self,
82 batch: &IntentBatch,
83 capability: &CapabilityLevel,
84 ) -> Vec<PolicyDecision> {
85 batch
86 .intents
87 .iter()
88 .map(|intent| {
89 self.evaluate_intent(intent, batch.generated_at_ms, Some(capability), None)
90 })
91 .collect()
92 }
93
94 pub fn evaluate_batch_with_context(
99 &self,
100 batch: &IntentBatch,
101 capability: &CapabilityLevel,
102 ctx: &StructuredContext,
103 ) -> Vec<PolicyDecision> {
104 let known = KnownTargets::from_context(ctx);
105 batch
106 .intents
107 .iter()
108 .map(|intent| {
109 self.evaluate_intent(
110 intent,
111 batch.generated_at_ms,
112 Some(capability),
113 Some(&known),
114 )
115 })
116 .collect()
117 }
118
119 fn evaluate_intent(
120 &self,
121 intent: &Intent,
122 authorized_at_ms: i64,
123 capability: Option<&CapabilityLevel>,
124 known: Option<&KnownTargets>,
125 ) -> PolicyDecision {
126 if let Some(cap) = capability {
128 if !cap.allows_risk(intent.risk_level) {
129 return rejected(
130 intent,
131 DenialReason::RiskExceedsCapability,
132 "risk exceeds backend capability",
133 );
134 }
135 }
136
137 if intent.risk_level as u8 > self.config.max_auto_risk as u8 {
139 return rejected(
140 intent,
141 DenialReason::RiskExceedsConfig,
142 "risk exceeds engine config",
143 );
144 }
145
146 if intent.confidence < self.config.min_confidence {
148 return rejected(
149 intent,
150 DenialReason::ConfidenceTooLow,
151 "confidence below floor",
152 );
153 }
154
155 let mut approved_actions: Vec<AuthorizedAction> = Vec::new();
157 let mut action_denials: Vec<DenialReason> = Vec::new();
158
159 for action in &intent.suggested_actions {
160 if approved_actions.len() >= self.config.max_actions_per_batch {
161 debug!(
162 intent_id = %intent.intent_id,
163 reason = ?DenialReason::BatchActionCapExceeded,
164 "policy denial"
165 );
166 action_denials.push(DenialReason::BatchActionCapExceeded);
167 continue;
168 }
169
170 let action_name = format!("{:?}", action.action_type);
171 if self
172 .config
173 .blocked_actions
174 .iter()
175 .any(|blocked| action_name.contains(blocked))
176 {
177 debug!(
178 intent_id = %intent.intent_id,
179 reason = ?DenialReason::ActionTypeBlocked,
180 action = %action_name,
181 "policy denial"
182 );
183 action_denials.push(DenialReason::ActionTypeBlocked);
184 continue;
185 }
186
187 if matches!(action.urgency, ActionUrgency::Deferred) {
188 debug!(
189 intent_id = %intent.intent_id,
190 reason = ?DenialReason::ActionUrgencyDeferred,
191 "policy denial"
192 );
193 action_denials.push(DenialReason::ActionUrgencyDeferred);
194 continue;
195 }
196
197 if let Some(cap) = capability {
198 if !cap.allows_action(&action.action_type) {
199 debug!(
200 intent_id = %intent.intent_id,
201 reason = ?DenialReason::ActionCapabilityDenied,
202 action = %action_name,
203 "policy denial"
204 );
205 action_denials.push(DenialReason::ActionCapabilityDenied);
206 continue;
207 }
208 }
209
210 if let Some(k) = known {
211 if let Some(reason) = check_target(&action.action_type, action.target.as_deref(), k)
212 {
213 debug!(
214 intent_id = %intent.intent_id,
215 reason = ?reason,
216 target = ?action.target,
217 "policy denial"
218 );
219 action_denials.push(reason);
220 continue;
221 }
222 }
223
224 approved_actions.push(AuthorizedAction {
225 intent_id: intent.intent_id.clone(),
226 action: action.clone(),
227 authorized_at_ms,
228 });
229 }
230
231 PolicyDecision {
232 intent_id: intent.intent_id.clone(),
233 approved: true,
234 rejection_reason: None,
235 action_denials,
236 approved_actions,
237 }
238 }
239}
240
241impl Default for PolicyEngine {
242 fn default() -> Self {
243 Self::new(PolicyConfig::default())
244 }
245}
246
247fn rejected(intent: &Intent, reason: DenialReason, log_msg: &'static str) -> PolicyDecision {
248 debug!(
249 intent_id = %intent.intent_id,
250 reason = ?reason,
251 "policy denial: {log_msg}"
252 );
253 PolicyDecision {
254 intent_id: intent.intent_id.clone(),
255 approved: false,
256 rejection_reason: Some(reason),
257 action_denials: vec![],
258 approved_actions: vec![],
259 }
260}
261
262fn check_target(
273 action: &ActionType,
274 target: Option<&str>,
275 known: &KnownTargets,
276) -> Option<DenialReason> {
277 match action {
278 ActionType::NoOp => None,
279 ActionType::PreWarmProcess => match target {
280 Some(t) if !t.is_empty() && (known.packages.contains(t) || known.files.contains(t)) => {
281 None
282 },
283 _ => Some(DenialReason::TargetNotInContext),
284 },
285 ActionType::KeepAlive | ActionType::ReleaseMemory | ActionType::PrefetchFile => {
286 match target {
287 None => None,
288 Some("") => Some(DenialReason::TargetNotInContext),
289 Some(t) if known.packages.contains(t) || known.files.contains(t) => None,
290 Some(_) => Some(DenialReason::TargetNotInContext),
291 }
292 },
293 }
294}
295
296struct KnownTargets {
302 packages: BTreeSet<String>,
303 files: BTreeSet<String>,
304}
305
306impl KnownTargets {
307 fn from_context(ctx: &StructuredContext) -> Self {
308 let mut packages: BTreeSet<String> = BTreeSet::new();
309 for pkg in &ctx.summary.foreground_apps {
310 packages.insert(pkg.clone());
311 }
312 for pkg in &ctx.summary.notified_apps {
313 packages.insert(pkg.clone());
314 }
315 for event in &ctx.events {
316 if let Some(pkg) = event.app_package.as_ref() {
317 packages.insert(pkg.clone());
318 }
319 match &event.event_type {
320 SanitizedEventType::AppTransition { package_name, .. } => {
321 packages.insert(package_name.clone());
322 },
323 SanitizedEventType::Notification { source_package, .. } => {
324 packages.insert(source_package.clone());
325 },
326 SanitizedEventType::ProcessResource {
327 package_name: Some(p),
328 ..
329 } => {
330 packages.insert(p.clone());
331 },
332 _ => {},
333 }
334 }
335 Self {
336 packages,
337 files: BTreeSet::new(),
338 }
339 }
340}