Skip to main content

libverify_core/
aiops.rs

1//! Convenience API for AI-ops (agent-driven) verification.
2//!
3//! Provides a high-level function that builds evidence, selects controls,
4//! and evaluates — reducing integration from ~40 lines to ~5.
5
6use crate::assessment::AssessmentReport;
7use crate::control::evaluate_all;
8use crate::controls::aiops_controls;
9use crate::evidence::*;
10use crate::profile::{ControlProfile, apply_profile};
11
12/// Input for a single agent action (simplified builder input).
13pub struct ActionInput {
14    pub tool: String,
15    pub command: String,
16}
17
18/// High-level input for assessing an agent session.
19pub 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
32/// Build an `EvidenceBundle` from agent session input.
33pub 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
77/// Assess an agent session against AI-ops controls.
78///
79/// Returns findings from the 4 AI-ops controls
80/// (agent-spec-conformance, privileged-operation-audit, mcp-scope-check, network-egress-audit).
81///
82/// Use with an OPA profile for gate decisions:
83/// ```ignore
84/// let report = assess_session(&input, &profile);
85/// ```
86pub 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    /// Minimal pass-through profile for testing.
108    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        // spec-conformance should fail (forbidden paths, unauthorized tools, over budget/steps)
226        // privileged-operation-audit: empty privileged_events + rm -rf in action log -> Violated
227        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}