1use crate::PolicyBundle;
14use agentguard_core::authorize::entities::build_entities;
15use agentguard_core::ttl::Clock;
16use agentguard_core::{AgentRequest, Authorizer, Effect};
17use std::sync::Arc;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ChangeClass {
22 Unchanged,
23 AllowToDeny,
24 DenyToAllow,
25 Other(String),
26}
27
28impl ChangeClass {
29 pub fn is_dangerous(&self) -> bool {
30 matches!(self, ChangeClass::AllowToDeny)
31 }
32}
33
34#[derive(Debug, Clone)]
37pub struct ReplayRequest {
38 pub request: Box<AgentRequest>,
39 pub entities: Vec<serde_json::Value>,
40 #[allow(dead_code)]
43 pub old_effect: String,
44}
45
46#[derive(Debug, Default, Clone)]
48pub struct BlastRadiusReport {
49 pub unchanged: usize,
50 pub allow_to_deny: usize,
51 pub deny_to_allow: usize,
52 pub other: usize,
53 pub sample_allow_to_deny: Vec<ReplayRequest>,
55 pub sample_deny_to_allow: Vec<ReplayRequest>,
57}
58
59impl BlastRadiusReport {
60 pub fn total(&self) -> usize {
62 self.unchanged + self.allow_to_deny + self.deny_to_allow + self.other
63 }
64
65 pub fn has_allow_to_deny(&self) -> bool {
67 self.allow_to_deny > 0
68 }
69}
70
71impl std::fmt::Display for BlastRadiusReport {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 writeln!(f, "blast-radius report:")?;
74 writeln!(f, " unchanged: {}", self.unchanged)?;
75 writeln!(f, " allow→deny: {} (DANGEROUS)", self.allow_to_deny)?;
76 writeln!(f, " deny→allow: {}", self.deny_to_allow)?;
77 if self.other > 0 {
78 writeln!(f, " other: {}", self.other)?;
79 }
80 if self.has_allow_to_deny() {
81 writeln!(f, " WARNING: at least one allow→deny flip detected")?;
82 }
83 Ok(())
84 }
85}
86
87fn classify(old: Effect, new: Effect) -> ChangeClass {
89 match (old, new) {
90 (Effect::Allow, Effect::Deny) => ChangeClass::AllowToDeny,
91 (Effect::Deny, Effect::Allow) => ChangeClass::DenyToAllow,
92 (a, b) if a == b => ChangeClass::Unchanged,
93 (a, b) => ChangeClass::Other(format!("{:?}→{:?}", a, b)),
94 }
95}
96
97const SAMPLE_CAP: usize = 10;
99
100pub fn analyze(
158 old: &PolicyBundle,
159 new: &PolicyBundle,
160 replay: &[ReplayRequest],
161 clock: &dyn Clock,
162) -> std::result::Result<BlastRadiusReport, String> {
163 let _ = clock; let old_authorizer = Arc::new(build_authorizer(old)?);
165 let new_authorizer = Arc::new(build_authorizer(new)?);
166
167 let mut report = BlastRadiusReport::default();
168 for req in replay {
169 let entities = build_entities(req.entities.clone())
170 .map_err(|e| format!("replay entity build: {}", e))?;
171
172 let old_decision = old_authorizer
173 .authorize(&req.request, &entities)
174 .map_err(|e| format!("old-bundle authorize: {}", e))?;
175 let new_decision = new_authorizer
176 .authorize(&req.request, &entities)
177 .map_err(|e| format!("new-bundle authorize: {}", e))?;
178
179 match classify(old_decision.effect, new_decision.effect) {
180 ChangeClass::Unchanged => report.unchanged += 1,
181 ChangeClass::AllowToDeny => {
182 report.allow_to_deny += 1;
183 if report.sample_allow_to_deny.len() < SAMPLE_CAP {
184 report.sample_allow_to_deny.push(req.clone());
185 }
186 }
187 ChangeClass::DenyToAllow => {
188 report.deny_to_allow += 1;
189 if report.sample_deny_to_allow.len() < SAMPLE_CAP {
190 report.sample_deny_to_allow.push(req.clone());
191 }
192 }
193 ChangeClass::Other(_) => report.other += 1,
194 }
195 }
196 Ok(report)
197}
198
199fn build_authorizer(bundle: &PolicyBundle) -> std::result::Result<Authorizer, String> {
206 use agentguard_core::PolicyStore;
207 use std::path::Path;
208
209 let _tmp = tempfile::tempdir().map_err(|e| format!("create tempdir: {}", e))?;
216 let dir = _tmp.path();
217 let schema_path = dir.join("schema.cedarschema");
218 let policies_dir = dir.join("policies");
219 std::fs::create_dir_all(&policies_dir).map_err(|e| format!("create policies dir: {}", e))?;
220 std::fs::write(&schema_path, &bundle.schema_source)
221 .map_err(|e| format!("write schema: {}", e))?;
222 for (i, p) in bundle.policies.iter().enumerate() {
223 let path = policies_dir.join(format!("{:03}_{}.cedar", i, p.id));
224 std::fs::write(&path, &p.source).map_err(|e| format!("write policy {}: {}", p.id, e))?;
225 }
226 let store =
227 PolicyStore::open(Path::new(dir)).map_err(|e| format!("open policy store: {}", e))?;
228 Authorizer::new(store).map_err(|e| format!("build authorizer: {}", e))
229}
230
231#[allow(dead_code, deprecated)]
232fn tempdir_in(_prefix: &str) -> std::result::Result<std::path::PathBuf, String> {
233 let tmp = tempfile::Builder::new()
238 .prefix("agentguard-blast-")
239 .tempdir()
240 .map_err(|e| format!("create tempdir: {}", e))?;
241 Ok(tmp.keep())
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::version::PolicyVersion;
248 use crate::{NamedPolicy, PolicyBundle};
249 use agentguard_core::ttl::SystemClock;
250 use agentguard_core::{AgentAction, AgentContext, AgentRequestBuilder, Principal, Resource};
251 use serde_json::json;
252
253 fn bundle(schema: &str, policy_name: &str, policy: &str) -> PolicyBundle {
254 PolicyBundle {
255 version: PolicyVersion::new(1),
256 tenant_id: "test".into(),
257 schema_hash: [0u8; 32],
258 policies_hash: [0u8; 32],
259 created_at: 0,
260 created_by: "test".into(),
261 signature: None,
262 schema_source: schema.into(),
263 policies: vec![NamedPolicy {
264 id: policy_name.into(),
265 source: policy.into(),
266 }],
267 }
268 }
269
270 fn make_request() -> ReplayRequest {
271 ReplayRequest {
272 request: Box::new(
273 AgentRequestBuilder::new(Principal::user("alice"))
274 .action(AgentAction::tool("send_email"))
275 .resource(Resource::new("Mailbox", "alice@acme"))
276 .context(AgentContext::new())
277 .build()
278 .unwrap(),
279 ),
280 entities: vec![],
281 old_effect: "allow".into(),
282 }
283 }
284
285 const SCHEMA: &str = r#"
286entity User;
287entity Mailbox;
288action "ToolCall::send_email" appliesTo { principal: [User], resource: [Mailbox] };
289"#;
290
291 fn allow_alice_email() -> &'static str {
292 r#"permit (principal in User::"alice", action == Action::"ToolCall::send_email", resource == Mailbox::"alice@acme");"#
293 }
294
295 fn deny_alice_email() -> &'static str {
296 r#"forbid (principal, action, resource);"#
297 }
298
299 #[test]
300 fn change_class_dangerous() {
301 assert!(ChangeClass::AllowToDeny.is_dangerous());
302 assert!(!ChangeClass::DenyToAllow.is_dangerous());
303 assert!(!ChangeClass::Unchanged.is_dangerous());
304 assert!(!matches!(
305 ChangeClass::Other("foo".into()),
306 ChangeClass::Unchanged
307 ));
308 }
309
310 #[test]
311 fn empty_report() {
312 let r = BlastRadiusReport::default();
313 assert_eq!(r.total(), 0);
314 assert!(!r.has_allow_to_deny());
315 }
316
317 #[test]
318 fn analyze_classifies_unchanged() {
319 let old = bundle(SCHEMA, "p", allow_alice_email());
320 let new = bundle(SCHEMA, "p", allow_alice_email());
321 let report = analyze(&old, &new, &[make_request()], &SystemClock).unwrap();
322 assert_eq!(report.total(), 1);
323 assert_eq!(report.unchanged, 1);
324 assert_eq!(report.allow_to_deny, 0);
325 assert_eq!(report.deny_to_allow, 0);
326 }
327
328 #[test]
329 fn analyze_classifies_allow_to_deny() {
330 let old = bundle(SCHEMA, "p", allow_alice_email());
331 let new = bundle(SCHEMA, "p", deny_alice_email());
332 let report = analyze(&old, &new, &[make_request()], &SystemClock).unwrap();
333 assert_eq!(report.total(), 1);
334 assert_eq!(report.allow_to_deny, 1);
335 assert_eq!(report.sample_allow_to_deny.len(), 1);
336 assert!(report.has_allow_to_deny());
337 }
338
339 #[test]
340 fn analyze_classifies_deny_to_allow() {
341 let old = bundle(SCHEMA, "p", deny_alice_email());
342 let new = bundle(SCHEMA, "p", allow_alice_email());
343 let report = analyze(&old, &new, &[make_request()], &SystemClock).unwrap();
344 assert_eq!(report.deny_to_allow, 1);
345 assert_eq!(report.sample_deny_to_allow.len(), 1);
346 }
347
348 #[test]
349 fn analyze_samples_capped_at_ten() {
350 let old = bundle(SCHEMA, "p", allow_alice_email());
351 let new = bundle(SCHEMA, "p", deny_alice_email());
352 let corpus: Vec<ReplayRequest> = (0..12).map(|_| make_request()).collect();
353 let report = analyze(&old, &new, &corpus, &SystemClock).unwrap();
354 assert_eq!(report.allow_to_deny, 12);
355 assert_eq!(report.sample_allow_to_deny.len(), SAMPLE_CAP);
356 }
357
358 #[test]
359 fn analyze_mixed_corpus_counts_correctly() {
360 let old = bundle(SCHEMA, "p", allow_alice_email());
362 let new = bundle(SCHEMA, "p", deny_alice_email());
363 let corpus: Vec<ReplayRequest> = (0..6).map(|_| make_request()).collect();
364 let report = analyze(&old, &new, &corpus, &SystemClock).unwrap();
365 assert_eq!(report.total(), 6);
366 assert_eq!(report.allow_to_deny, 6);
367 }
368
369 #[test]
370 fn analyze_rejects_malformed_bundle() {
371 let bad = PolicyBundle {
372 version: PolicyVersion::new(1),
373 tenant_id: "test".into(),
374 schema_hash: [0u8; 32],
375 policies_hash: [0u8; 32],
376 created_at: 0,
377 created_by: "test".into(),
378 signature: None,
379 schema_source: "this is not a valid schema".into(),
380 policies: vec![],
381 };
382 let res = analyze(&bad, &bad, &[make_request()], &SystemClock);
383 assert!(res.is_err());
384 }
385
386 #[test]
387 fn analyze_with_entities() {
388 let old = bundle(SCHEMA, "p", allow_alice_email());
391 let new = bundle(SCHEMA, "p", allow_alice_email());
392 let mut req = make_request();
393 req.entities = vec![
394 json!({"uid": {"type": "Mailbox", "id": "alice@acme"}, "attrs": {}, "parents": []}),
395 ];
396 let report = analyze(&old, &new, &[req], &SystemClock).unwrap();
397 assert_eq!(report.unchanged, 1);
398 }
399}