Skip to main content

assay_core/explain_next/
source.rs

1use crate::model::Policy;
2
3use super::diff::ExplainerState;
4use super::model::{ExplainedStep, RuleEvaluation, StepVerdict, ToolCall, TraceExplanation};
5
6/// Trace explainer
7pub struct TraceExplainer {
8    policy: Policy,
9}
10
11impl TraceExplainer {
12    pub fn new(policy: Policy) -> Self {
13        Self { policy }
14    }
15
16    /// Explain a trace step by step
17    pub fn explain(&self, trace: &[ToolCall]) -> TraceExplanation {
18        let mut steps = Vec::new();
19        let mut state = ExplainerState::new(&self.policy);
20        let mut first_block_index = None;
21        let mut blocking_rules = Vec::new();
22
23        for (idx, call) in trace.iter().enumerate() {
24            let (step, blocked_by) = self.explain_step(idx, call, &mut state);
25
26            if step.verdict == StepVerdict::Blocked && first_block_index.is_none() {
27                first_block_index = Some(idx);
28            }
29
30            if let Some(rule) = blocked_by {
31                if !blocking_rules.contains(&rule) {
32                    blocking_rules.push(rule);
33                }
34            }
35
36            steps.push(step);
37        }
38
39        // Check end-of-trace constraints
40        let end_violations = state.check_end_of_trace(&self.policy);
41        if !end_violations.is_empty() && !steps.is_empty() {
42            let last_idx = steps.len() - 1;
43            for violation in end_violations {
44                steps[last_idx].rules_evaluated.push(violation.clone());
45                if !blocking_rules.contains(&violation.rule_id) {
46                    blocking_rules.push(violation.rule_id);
47                }
48            }
49        }
50
51        let allowed_steps = steps
52            .iter()
53            .filter(|s| s.verdict == StepVerdict::Allowed)
54            .count();
55        let blocked_steps = steps
56            .iter()
57            .filter(|s| s.verdict == StepVerdict::Blocked)
58            .count();
59
60        TraceExplanation {
61            policy_name: self.policy.name.clone(),
62            policy_version: self.policy.version.clone(),
63            total_steps: steps.len(),
64            allowed_steps,
65            blocked_steps,
66            first_block_index,
67            steps,
68            blocking_rules,
69        }
70    }
71
72    fn explain_step(
73        &self,
74        idx: usize,
75        call: &ToolCall,
76        state: &mut ExplainerState,
77    ) -> (ExplainedStep, Option<String>) {
78        let mut rules_evaluated = Vec::new();
79        let mut verdict = StepVerdict::Allowed;
80        let mut blocked_by = None;
81
82        // Check static constraints (allow/deny lists)
83        if let Some(eval) = self.check_static_constraints(&call.tool) {
84            if !eval.passed {
85                verdict = StepVerdict::Blocked;
86                blocked_by = Some(eval.rule_id.clone());
87            }
88            rules_evaluated.push(eval);
89        }
90
91        // Check each sequence rule
92        for (rule_idx, rule) in self.policy.sequences.iter().enumerate() {
93            let eval = state.evaluate_rule(rule_idx, rule, &call.tool, idx);
94
95            if !eval.passed && verdict != StepVerdict::Blocked {
96                verdict = StepVerdict::Blocked;
97                blocked_by = Some(eval.rule_id.clone());
98            }
99
100            rules_evaluated.push(eval);
101        }
102
103        // Update state after evaluation
104        state.update(&call.tool, idx, &self.policy);
105
106        let step = ExplainedStep {
107            index: idx,
108            tool: call.tool.clone(),
109            args: call.args.clone(),
110            verdict,
111            rules_evaluated,
112            state_snapshot: state.snapshot(),
113        };
114
115        (step, blocked_by)
116    }
117
118    fn check_static_constraints(&self, tool: &str) -> Option<RuleEvaluation> {
119        // Check deny list first
120        if let Some(deny) = &self.policy.tools.deny {
121            if deny.contains(&tool.to_string()) {
122                return Some(RuleEvaluation {
123                    rule_id: "deny_list".to_string(),
124                    rule_type: "deny".to_string(),
125                    passed: false,
126                    explanation: format!("Tool '{}' is in deny list", tool),
127                    context: None,
128                });
129            }
130        }
131
132        // Check allow list
133        if let Some(allow) = &self.policy.tools.allow {
134            if !allow.contains(&tool.to_string()) && !self.is_alias_member(tool) {
135                return Some(RuleEvaluation {
136                    rule_id: "allow_list".to_string(),
137                    rule_type: "allow".to_string(),
138                    passed: false,
139                    explanation: format!("Tool '{}' is not in allow list", tool),
140                    context: None,
141                });
142            }
143        }
144
145        None
146    }
147
148    fn is_alias_member(&self, tool: &str) -> bool {
149        for members in self.policy.aliases.values() {
150            if members.contains(&tool.to_string()) {
151                return true;
152            }
153        }
154        false
155    }
156}