agentguard_policy/
blast_radius.rs

1//! Blast-radius analysis: classify decision changes across a replay corpus.
2//!
3//! A blast-radius analysis replays a corpus of past authorization requests
4//! against both the old and the new policy bundle, then classifies each
5//! request as `Unchanged`, `AllowToDeny` (the dangerous direction),
6//! `DenyToAllow` (often a bug), or `Other` (transitions between Deny and
7//! an error state, etc.).
8//!
9//! Complexity: O(N × P) where N = replay corpus size, P = policy set size.
10//! The Authorizer for each bundle is constructed once and reused across
11//! the corpus, so the dominant cost is `O(N)` cedar policy evaluations.
12
13use 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/// Classification of how a single replay request's decision changed.
20#[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/// A replay request — a serialized [`agentguard_core::AgentRequest`]
35/// plus the entities that should be loaded with it.
36#[derive(Debug, Clone)]
37pub struct ReplayRequest {
38    pub request: Box<AgentRequest>,
39    pub entities: Vec<serde_json::Value>,
40    /// Expected authorization effect under the old bundle. Stored so
41    /// callers can sanity-check their corpus before running analysis.
42    #[allow(dead_code)]
43    pub old_effect: String,
44}
45
46/// Aggregate blast-radius report.
47#[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    /// One example per observed `AllowToDeny` (capped at 10).
54    pub sample_allow_to_deny: Vec<ReplayRequest>,
55    /// One example per observed `DenyToAllow` (capped at 10).
56    pub sample_deny_to_allow: Vec<ReplayRequest>,
57}
58
59impl BlastRadiusReport {
60    /// Total number of replay requests classified.
61    pub fn total(&self) -> usize {
62        self.unchanged + self.allow_to_deny + self.deny_to_allow + self.other
63    }
64
65    /// `true` if any request that was `Allow` is now `Deny`.
66    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
87/// Classify the change between two cedar effects.
88fn 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
97/// Maximum number of sample requests kept per category in the report.
98const SAMPLE_CAP: usize = 10;
99
100/// Replay the corpus against both bundles and classify the changes.
101///
102/// Each `ReplayRequest` is evaluated by the old `Authorizer` and the new
103/// `Authorizer`. The pair of decisions is classified; samples of
104/// `AllowToDeny` and `DenyToAllow` flips are retained (capped at
105/// `SAMPLE_CAP`) for debugging and audit.
106///
107/// # Errors
108/// Returns an error string if either bundle's schema or policy set fails
109/// to parse. The replay engine itself is infallible (the cedar
110/// `Authorizer::authorize` does not return an error for a well-formed
111/// request — cedar policy evaluation is total).
112///
113/// # Examples
114/// ```
115/// use agentguard_policy::blast_radius::{analyze, ReplayRequest};
116/// use agentguard_policy::{NamedPolicy, PolicyBundle};
117/// use agentguard_policy::version::PolicyVersion;
118/// use agentguard_core::AgentRequestBuilder;
119/// use agentguard_core::{Principal, AgentAction, Resource, AgentContext};
120/// use agentguard_core::ttl::SystemClock;
121///
122/// let schema = r#"entity User;
123/// entity Mailbox;
124/// action "ToolCall::send_email" appliesTo { principal: [User], resource: [Mailbox] };"#;
125/// let bundle = |src: &str| PolicyBundle {
126///     version: PolicyVersion::new(1),
127///     tenant_id: "test".into(),
128///     schema_hash: [0u8; 32],
129///     policies_hash: [0u8; 32],
130///     created_at: 0,
131///     created_by: "test".into(),
132///     signature: None,
133///     schema_source: schema.into(),
134///     policies: vec![NamedPolicy { id: "p".into(), source: src.into() }],
135/// };
136/// let req = ReplayRequest {
137///     request: Box::new(
138///         AgentRequestBuilder::new(Principal::user("alice"))
139///             .action(AgentAction::tool("send_email"))
140///             .resource(Resource::new("Mailbox", "alice@acme"))
141///             .context(AgentContext::new())
142///             .build()
143///             .unwrap()
144///     ),
145///     entities: vec![],
146///     old_effect: "allow".into(),
147/// };
148/// let report = analyze(
149///     &bundle(r#"permit (principal in User::"alice", action == Action::"ToolCall::send_email", resource == Mailbox::"alice@acme");"#),
150///     &bundle(r#"forbid (principal, action, resource);"#),
151///     &[req],
152///     &SystemClock,
153/// )
154/// .unwrap();
155/// assert_eq!(report.allow_to_deny, 1);
156/// ```
157pub fn analyze(
158    old: &PolicyBundle,
159    new: &PolicyBundle,
160    replay: &[ReplayRequest],
161    clock: &dyn Clock,
162) -> std::result::Result<BlastRadiusReport, String> {
163    let _ = clock; // Reserved for future per-replay time-stamping.
164    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
199/// Build an `Authorizer` from a `PolicyBundle` by parsing its `schema_source`
200/// and policy text.
201///
202/// # Errors
203/// Returns an error string if the schema or policy text is malformed
204/// (mirrors the kinds of errors `PolicyStore::validate` produces).
205fn build_authorizer(bundle: &PolicyBundle) -> std::result::Result<Authorizer, String> {
206    use agentguard_core::PolicyStore;
207    use std::path::Path;
208
209    // RAII tempdir: the directory is removed when `_tmp` is dropped at
210    // the end of this function (before we return the Authorizer, which
211    // holds parsed policies in memory, not file handles). The previous
212    // implementation created a fresh /tmp/agentguard-blast-* dir on
213    // every call and never cleaned it up; a corpus analysis loop
214    // would fill the disk.
215    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    // Retained for compatibility with downstream embedders; internally
234    // `build_authorizer` uses `tempfile::tempdir()` for RAII cleanup.
235    // This function now also uses `tempfile::tempdir()` and re-rooted
236    // its prefix as a hint to the underlying builder.
237    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        // 3 unchanged + 2 allow→deny + 1 deny→allow
361        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        // The replay_request.entities must be passed to the Authorizer.
389        // If the entity list is malformed, authorize fails — we propagate.
390        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}