assay_core/explain_next/
source.rs1use crate::model::Policy;
2
3use super::diff::ExplainerState;
4use super::model::{ExplainedStep, RuleEvaluation, StepVerdict, ToolCall, TraceExplanation};
5
6pub struct TraceExplainer {
8 policy: Policy,
9}
10
11impl TraceExplainer {
12 pub fn new(policy: Policy) -> Self {
13 Self { policy }
14 }
15
16 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 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 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 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 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 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 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}