1use crate::assessment::AssessmentReport;
7use crate::control::evaluate_all;
8use crate::controls::aiops_controls;
9use crate::evidence::*;
10use crate::profile::{ControlProfile, apply_profile};
11
12pub struct ActionInput {
14 pub tool: String,
15 pub command: String,
16}
17
18pub struct SessionInput {
20 pub agent_id: String,
21 pub session_id: String,
22 pub actions: Vec<ActionInput>,
23 pub spec: AgentSpec,
24 pub files_touched: Vec<String>,
25 pub tools_used: Vec<String>,
26 pub steps_taken: u32,
27 pub cost_cents: u32,
28 pub check_runs: Vec<CheckRunEvidence>,
29 pub privileged_events: Vec<PrivilegedGitEvent>,
30}
31
32pub fn build_evidence(input: &SessionInput) -> EvidenceBundle {
34 let actions: Vec<AgentAction> = input
35 .actions
36 .iter()
37 .map(|a| AgentAction {
38 tool: a.tool.clone(),
39 command: a.command.clone(),
40 timestamp: None,
41 })
42 .collect();
43
44 let action_log = AgentActionLog {
45 agent_id: input.agent_id.clone(),
46 session_id: input.session_id.clone(),
47 actions,
48 };
49
50 let execution = AgentExecution {
51 agent_id: input.agent_id.clone(),
52 session_id: input.session_id.clone(),
53 files_touched: input.files_touched.clone(),
54 tools_used: input.tools_used.clone(),
55 steps_taken: input.steps_taken,
56 cost_cents: input.cost_cents,
57 };
58
59 EvidenceBundle {
60 check_runs: if input.check_runs.is_empty() {
61 EvidenceState::not_applicable()
62 } else {
63 EvidenceState::complete(input.check_runs.clone())
64 },
65 agent_action_log: EvidenceState::complete(action_log),
66 agent_spec: EvidenceState::complete(input.spec.clone()),
67 agent_execution: EvidenceState::complete(execution),
68 privileged_git_events: if input.privileged_events.is_empty() {
69 EvidenceState::complete(vec![])
70 } else {
71 EvidenceState::complete(input.privileged_events.clone())
72 },
73 ..Default::default()
74 }
75}
76
77pub fn assess_session(input: &SessionInput, profile: &dyn ControlProfile) -> AssessmentReport {
87 let evidence = build_evidence(input);
88 let controls = aiops_controls();
89 let findings = evaluate_all(&controls, &evidence);
90 let outcomes = apply_profile(profile, &findings);
91 let severity_labels = profile.severity_labels();
92
93 AssessmentReport {
94 profile_name: "aiops".to_string(),
95 findings,
96 outcomes,
97 severity_labels,
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::control::ControlStatus;
105 use crate::profile::{FindingSeverity, GateDecision, ProfileOutcome, SeverityLabels};
106
107 struct TestProfile;
109 impl ControlProfile for TestProfile {
110 fn name(&self) -> &str {
111 "test"
112 }
113 fn map(&self, finding: &crate::control::ControlFinding) -> ProfileOutcome {
114 let (severity, decision) = match finding.status {
115 ControlStatus::Satisfied => (FindingSeverity::Info, GateDecision::Pass),
116 ControlStatus::Violated => (FindingSeverity::Error, GateDecision::Fail),
117 ControlStatus::Indeterminate => (FindingSeverity::Warning, GateDecision::Review),
118 ControlStatus::NotApplicable => (FindingSeverity::Info, GateDecision::Pass),
119 };
120 ProfileOutcome {
121 control_id: finding.control_id.clone(),
122 severity,
123 decision,
124 rationale: finding.rationale.clone(),
125 annotations: Default::default(),
126 }
127 }
128 fn severity_labels(&self) -> SeverityLabels {
129 SeverityLabels::default()
130 }
131 }
132
133 #[test]
134 fn happy_path_all_pass() {
135 let input = SessionInput {
136 agent_id: "agent-1".into(),
137 session_id: "sess-1".into(),
138 actions: vec![
139 ActionInput {
140 tool: "cargo".into(),
141 command: "cargo build".into(),
142 },
143 ActionInput {
144 tool: "cargo".into(),
145 command: "cargo test".into(),
146 },
147 ],
148 spec: AgentSpec {
149 allowed_paths: vec!["src/*".into()],
150 forbidden_paths: vec![".env".into()],
151 allowed_tools: vec!["cargo".into()],
152 max_steps: Some(100),
153 budget_cents: Some(5000),
154 ..Default::default()
155 },
156 files_touched: vec!["src/main.rs".into()],
157 tools_used: vec!["cargo".into()],
158 steps_taken: 10,
159 cost_cents: 500,
160 check_runs: vec![
161 CheckRunEvidence {
162 name: "ci/build".into(),
163 conclusion: CheckConclusion::Success,
164 app_slug: None,
165 },
166 CheckRunEvidence {
167 name: "ci/test".into(),
168 conclusion: CheckConclusion::Success,
169 app_slug: None,
170 },
171 CheckRunEvidence {
172 name: "ci/lint".into(),
173 conclusion: CheckConclusion::Success,
174 app_slug: None,
175 },
176 CheckRunEvidence {
177 name: "ci/typecheck".into(),
178 conclusion: CheckConclusion::Success,
179 app_slug: None,
180 },
181 ],
182 privileged_events: vec![],
183 };
184
185 let report = assess_session(&input, &TestProfile);
186 let pass_count = report
187 .outcomes
188 .iter()
189 .filter(|o| o.decision == GateDecision::Pass)
190 .count();
191 assert_eq!(pass_count, 4, "All 4 AI-ops controls should pass");
192 }
193
194 #[test]
195 fn rogue_agent_all_fail() {
196 let input = SessionInput {
197 agent_id: "rogue".into(),
198 session_id: "evil-sess".into(),
199 actions: vec![ActionInput {
200 tool: "shell".into(),
201 command: "rm -rf /".into(),
202 }],
203 spec: AgentSpec {
204 allowed_paths: vec!["src/*".into()],
205 forbidden_paths: vec![".env".into()],
206 allowed_tools: vec!["cargo".into()],
207 max_steps: Some(10),
208 budget_cents: Some(100),
209 ..Default::default()
210 },
211 files_touched: vec![".env".into()],
212 tools_used: vec!["shell".into()],
213 steps_taken: 50,
214 cost_cents: 500,
215 check_runs: vec![],
216 privileged_events: vec![],
217 };
218
219 let report = assess_session(&input, &TestProfile);
220 let fail_count = report
221 .outcomes
222 .iter()
223 .filter(|o| o.decision == GateDecision::Fail)
224 .count();
225 assert!(
228 fail_count >= 2,
229 "Both AI-ops controls should fail, got {fail_count}"
230 );
231 }
232
233 #[test]
234 fn build_evidence_includes_check_runs() {
235 let input = SessionInput {
236 agent_id: "a".into(),
237 session_id: "s".into(),
238 actions: vec![],
239 spec: AgentSpec::default(),
240 files_touched: vec![],
241 tools_used: vec![],
242 steps_taken: 0,
243 cost_cents: 0,
244 check_runs: vec![CheckRunEvidence {
245 name: "ci/build".into(),
246 conclusion: CheckConclusion::Success,
247 app_slug: None,
248 }],
249 privileged_events: vec![],
250 };
251 let evidence = build_evidence(&input);
252 let runs = evidence
253 .check_runs
254 .value()
255 .expect("check_runs should be Complete");
256 assert_eq!(runs.len(), 1);
257 assert_eq!(runs[0].name, "ci/build");
258 }
259
260 #[test]
261 fn build_evidence_empty_check_runs_is_not_applicable() {
262 let input = SessionInput {
263 agent_id: "a".into(),
264 session_id: "s".into(),
265 actions: vec![],
266 spec: AgentSpec::default(),
267 files_touched: vec![],
268 tools_used: vec![],
269 steps_taken: 0,
270 cost_cents: 0,
271 check_runs: vec![],
272 privileged_events: vec![],
273 };
274 let evidence = build_evidence(&input);
275 assert!(
276 evidence.check_runs.value().is_none(),
277 "empty check_runs should be NotApplicable"
278 );
279 }
280
281 #[test]
282 fn build_evidence_includes_privileged_events() {
283 use crate::evidence::{PrivilegedAction, PrivilegedGitEvent};
284 let input = SessionInput {
285 agent_id: "a".into(),
286 session_id: "s".into(),
287 actions: vec![],
288 spec: AgentSpec::default(),
289 files_touched: vec![],
290 tools_used: vec![],
291 steps_taken: 0,
292 cost_cents: 0,
293 check_runs: vec![],
294 privileged_events: vec![PrivilegedGitEvent {
295 actor: "bot".into(),
296 action: PrivilegedAction::ForcePush,
297 branch: Some("main".into()),
298 tag: None,
299 timestamp: None,
300 commit_sha: None,
301 detail: None,
302 }],
303 };
304 let evidence = build_evidence(&input);
305 let events = evidence
306 .privileged_git_events
307 .value()
308 .expect("events should be Complete");
309 assert_eq!(events.len(), 1);
310 assert_eq!(events[0].action, PrivilegedAction::ForcePush);
311 }
312}