Skip to main content

libverify_core/controls/
agent_spec_conformance.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{AgentExecution, AgentSpec, EvidenceBundle, EvidenceState};
3
4pub struct AgentSpecConformanceControl;
5
6/// Normalize a path by resolving `.` and `..` segments and collapsing separators.
7/// Does not touch the filesystem — purely lexical.
8fn normalize_path(path: &str) -> String {
9    let mut parts: Vec<&str> = Vec::new();
10    for segment in path.split('/') {
11        match segment {
12            "" | "." => {}
13            ".." => {
14                parts.pop();
15            }
16            s => parts.push(s),
17        }
18    }
19    parts.join("/")
20}
21
22/// Pattern ends with `*` or `/` -> prefix match (trailing char stripped).
23/// Otherwise -> exact match.
24/// All paths are normalized before matching to prevent traversal attacks.
25fn path_matches(path: &str, pattern: &str) -> bool {
26    let normalized = normalize_path(path);
27    if pattern.ends_with('*') || pattern.ends_with('/') {
28        let prefix = normalize_path(&pattern[..pattern.len() - 1]);
29        normalized.starts_with(&prefix)
30    } else {
31        normalized == normalize_path(pattern)
32    }
33}
34
35fn check_conformance(id: ControlId, spec: &AgentSpec, exec: &AgentExecution) -> ControlFinding {
36    let mut violations: Vec<String> = Vec::new();
37
38    // a. Forbidden paths
39    for file in &exec.files_touched {
40        for pattern in &spec.forbidden_paths {
41            if path_matches(file, pattern) {
42                violations.push(format!("touched forbidden path: {file}"));
43                break;
44            }
45        }
46    }
47
48    // b. Allowed paths (only enforced when non-empty)
49    if !spec.allowed_paths.is_empty() {
50        for file in &exec.files_touched {
51            let allowed = spec.allowed_paths.iter().any(|p| path_matches(file, p));
52            if !allowed {
53                violations.push(format!("touched path not in allowed list: {file}"));
54            }
55        }
56    }
57
58    // c. Allowed tools (only enforced when non-empty)
59    if !spec.allowed_tools.is_empty() {
60        for tool in &exec.tools_used {
61            if !spec.allowed_tools.contains(tool) {
62                violations.push(format!("used unauthorized tool: {tool}"));
63            }
64        }
65    }
66
67    // d. Step limit
68    if let Some(max) = spec.max_steps
69        && exec.steps_taken > max
70    {
71        violations.push(format!("exceeded step limit: {}/{}", exec.steps_taken, max));
72    }
73
74    // e. Budget limit
75    if let Some(max) = spec.budget_cents
76        && exec.cost_cents > max
77    {
78        violations.push(format!(
79            "exceeded budget: {}/{} cents",
80            exec.cost_cents, max
81        ));
82    }
83
84    if violations.is_empty() {
85        ControlFinding::satisfied(
86            id,
87            "Agent conformed to all spec constraints",
88            vec![exec.agent_id.clone()],
89        )
90    } else {
91        ControlFinding::violated(
92            id,
93            format!("Agent {} violated spec constraints", exec.agent_id),
94            violations,
95        )
96    }
97}
98
99impl Control for AgentSpecConformanceControl {
100    fn id(&self) -> ControlId {
101        builtin::id(builtin::AGENT_SPEC_CONFORMANCE)
102    }
103
104    fn description(&self) -> &'static str {
105        "Agent must conform to its spec (allowed paths, tools, budget)"
106    }
107
108    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
109        let spec = match &evidence.agent_spec {
110            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
111            EvidenceState::Missing { gaps } => {
112                return vec![ControlFinding::indeterminate(
113                    self.id(),
114                    "Agent spec evidence is missing",
115                    Vec::new(),
116                    gaps.clone(),
117                )];
118            }
119            EvidenceState::NotApplicable => {
120                return vec![ControlFinding::not_applicable(
121                    self.id(),
122                    "Agent spec evidence is not applicable",
123                )];
124            }
125        };
126
127        let exec = match &evidence.agent_execution {
128            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
129            EvidenceState::Missing { gaps } => {
130                return vec![ControlFinding::indeterminate(
131                    self.id(),
132                    "Agent execution evidence is missing",
133                    Vec::new(),
134                    gaps.clone(),
135                )];
136            }
137            EvidenceState::NotApplicable => {
138                return vec![ControlFinding::not_applicable(
139                    self.id(),
140                    "Agent execution evidence is not applicable",
141                )];
142            }
143        };
144
145        vec![check_conformance(self.id(), spec, exec)]
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::control::ControlStatus;
153
154    fn spec(
155        allowed_paths: Vec<&str>,
156        forbidden_paths: Vec<&str>,
157        allowed_tools: Vec<&str>,
158        max_steps: Option<u32>,
159        budget_cents: Option<u32>,
160    ) -> AgentSpec {
161        AgentSpec {
162            allowed_paths: allowed_paths.into_iter().map(String::from).collect(),
163            forbidden_paths: forbidden_paths.into_iter().map(String::from).collect(),
164            allowed_tools: allowed_tools.into_iter().map(String::from).collect(),
165            max_steps,
166            budget_cents,
167            custom_destructive_patterns: Vec::new(),
168        }
169    }
170
171    fn exec(files: Vec<&str>, tools: Vec<&str>, steps: u32, cost: u32) -> AgentExecution {
172        AgentExecution {
173            agent_id: "agent-1".to_string(),
174            session_id: "session-1".to_string(),
175            files_touched: files.into_iter().map(String::from).collect(),
176            tools_used: tools.into_iter().map(String::from).collect(),
177            steps_taken: steps,
178            cost_cents: cost,
179        }
180    }
181
182    fn bundle(s: AgentSpec, e: AgentExecution) -> EvidenceBundle {
183        EvidenceBundle {
184            agent_spec: EvidenceState::complete(s),
185            agent_execution: EvidenceState::complete(e),
186            ..Default::default()
187        }
188    }
189
190    // 1. All checks pass
191    #[test]
192    fn all_checks_pass() {
193        let b = bundle(
194            spec(
195                vec!["src/*"],
196                vec![".env"],
197                vec!["cargo"],
198                Some(100),
199                Some(2000),
200            ),
201            exec(vec!["src/main.rs"], vec!["cargo"], 50, 1000),
202        );
203        let findings = AgentSpecConformanceControl.evaluate(&b);
204        assert_eq!(findings[0].status, ControlStatus::Satisfied);
205    }
206
207    // 2. Touch forbidden path ".env"
208    #[test]
209    fn forbidden_path_exact() {
210        let b = bundle(
211            spec(vec![], vec![".env"], vec![], None, None),
212            exec(vec![".env"], vec![], 0, 0),
213        );
214        let findings = AgentSpecConformanceControl.evaluate(&b);
215        assert_eq!(findings[0].status, ControlStatus::Violated);
216        assert!(findings[0].subjects.iter().any(|s| s.contains(".env")));
217    }
218
219    // 3. Touch file not in allowed_paths
220    #[test]
221    fn file_not_in_allowed_paths() {
222        let b = bundle(
223            spec(vec!["src/*"], vec![], vec![], None, None),
224            exec(vec!["config/settings.toml"], vec![], 0, 0),
225        );
226        let findings = AgentSpecConformanceControl.evaluate(&b);
227        assert_eq!(findings[0].status, ControlStatus::Violated);
228        assert!(
229            findings[0]
230                .subjects
231                .iter()
232                .any(|s| s.contains("config/settings.toml"))
233        );
234    }
235
236    // 4. Use unauthorized tool
237    #[test]
238    fn unauthorized_tool() {
239        let b = bundle(
240            spec(vec![], vec![], vec!["cargo"], None, None),
241            exec(vec![], vec!["curl"], 0, 0),
242        );
243        let findings = AgentSpecConformanceControl.evaluate(&b);
244        assert_eq!(findings[0].status, ControlStatus::Violated);
245        assert!(findings[0].subjects.iter().any(|s| s.contains("curl")));
246    }
247
248    // 5. Exceed step limit
249    #[test]
250    fn exceed_step_limit() {
251        let b = bundle(
252            spec(vec![], vec![], vec![], Some(100), None),
253            exec(vec![], vec![], 150, 0),
254        );
255        let findings = AgentSpecConformanceControl.evaluate(&b);
256        assert_eq!(findings[0].status, ControlStatus::Violated);
257        assert!(findings[0].subjects.iter().any(|s| s.contains("150/100")));
258    }
259
260    // 6. Exceed budget
261    #[test]
262    fn exceed_budget() {
263        let b = bundle(
264            spec(vec![], vec![], vec![], None, Some(2000)),
265            exec(vec![], vec![], 0, 5000),
266        );
267        let findings = AgentSpecConformanceControl.evaluate(&b);
268        assert_eq!(findings[0].status, ControlStatus::Violated);
269        assert!(findings[0].subjects.iter().any(|s| s.contains("5000/2000")));
270    }
271
272    // 7. Multiple violations at once
273    #[test]
274    fn multiple_violations() {
275        let b = bundle(
276            spec(
277                vec!["src/*"],
278                vec![".env"],
279                vec!["cargo"],
280                Some(100),
281                Some(2000),
282            ),
283            exec(vec![".env", "docs/readme.md"], vec!["curl"], 150, 5000),
284        );
285        let findings = AgentSpecConformanceControl.evaluate(&b);
286        assert_eq!(findings[0].status, ControlStatus::Violated);
287        let subjects = &findings[0].subjects;
288        assert!(subjects.iter().any(|s| s.contains(".env")));
289        assert!(subjects.iter().any(|s| s.contains("docs/readme.md")));
290        assert!(subjects.iter().any(|s| s.contains("curl")));
291        assert!(subjects.iter().any(|s| s.contains("150/100")));
292        assert!(subjects.iter().any(|s| s.contains("5000/2000")));
293    }
294
295    // 8. Empty allowed_paths means no restriction
296    #[test]
297    fn empty_allowed_paths_no_restriction() {
298        let b = bundle(
299            spec(vec![], vec![], vec![], None, None),
300            exec(vec!["anywhere/file.txt"], vec![], 0, 0),
301        );
302        let findings = AgentSpecConformanceControl.evaluate(&b);
303        assert_eq!(findings[0].status, ControlStatus::Satisfied);
304    }
305
306    // 9. Empty allowed_tools means no restriction
307    #[test]
308    fn empty_allowed_tools_no_restriction() {
309        let b = bundle(
310            spec(vec![], vec![], vec![], None, None),
311            exec(vec![], vec!["anything"], 0, 0),
312        );
313        let findings = AgentSpecConformanceControl.evaluate(&b);
314        assert_eq!(findings[0].status, ControlStatus::Satisfied);
315    }
316
317    // 10. Prefix match: forbidden "secrets/" matches "secrets/api.key"
318    #[test]
319    fn forbidden_prefix_match_with_slash() {
320        let b = bundle(
321            spec(vec![], vec!["secrets/"], vec![], None, None),
322            exec(vec!["secrets/api.key"], vec![], 0, 0),
323        );
324        let findings = AgentSpecConformanceControl.evaluate(&b);
325        assert_eq!(findings[0].status, ControlStatus::Violated);
326        assert!(
327            findings[0]
328                .subjects
329                .iter()
330                .any(|s| s.contains("secrets/api.key"))
331        );
332    }
333
334    // 11. Wildcard match: allowed "src/*" matches "src/main.rs"
335    #[test]
336    fn allowed_wildcard_match() {
337        let b = bundle(
338            spec(vec!["src/*"], vec![], vec![], None, None),
339            exec(vec!["src/main.rs"], vec![], 0, 0),
340        );
341        let findings = AgentSpecConformanceControl.evaluate(&b);
342        assert_eq!(findings[0].status, ControlStatus::Satisfied);
343    }
344
345    // 12. Missing spec evidence -> Indeterminate
346    #[test]
347    fn missing_spec_indeterminate() {
348        let b = EvidenceBundle {
349            agent_spec: EvidenceState::missing(vec![]),
350            agent_execution: EvidenceState::complete(exec(vec![], vec![], 0, 0)),
351            ..Default::default()
352        };
353        let findings = AgentSpecConformanceControl.evaluate(&b);
354        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
355    }
356
357    // 13. NotApplicable execution -> NotApplicable
358    #[test]
359    fn not_applicable_execution() {
360        let b = EvidenceBundle {
361            agent_spec: EvidenceState::complete(spec(vec![], vec![], vec![], None, None)),
362            agent_execution: EvidenceState::not_applicable(),
363            ..Default::default()
364        };
365        let findings = AgentSpecConformanceControl.evaluate(&b);
366        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
367    }
368
369    // 14. Path traversal attack: src/../secrets/key.pem should NOT pass allowed "src/*"
370    #[test]
371    fn path_traversal_blocked() {
372        let b = bundle(
373            spec(vec!["src/*"], vec![], vec![], None, None),
374            exec(vec!["src/../secrets/key.pem"], vec![], 0, 0),
375        );
376        let findings = AgentSpecConformanceControl.evaluate(&b);
377        assert_eq!(findings[0].status, ControlStatus::Violated);
378        assert!(
379            findings[0]
380                .subjects
381                .iter()
382                .any(|s| s.contains("secrets/key.pem"))
383        );
384    }
385
386    // 15. Path traversal attack: src/../.env should match forbidden ".env"
387    #[test]
388    fn path_traversal_forbidden_detected() {
389        let b = bundle(
390            spec(vec![], vec![".env"], vec![], None, None),
391            exec(vec!["src/../.env"], vec![], 0, 0),
392        );
393        let findings = AgentSpecConformanceControl.evaluate(&b);
394        assert_eq!(findings[0].status, ControlStatus::Violated);
395    }
396
397    // 16. Normalized path: ./src/main.rs should match allowed "src/*"
398    #[test]
399    fn dot_prefix_normalized() {
400        let b = bundle(
401            spec(vec!["src/*"], vec![], vec![], None, None),
402            exec(vec!["./src/main.rs"], vec![], 0, 0),
403        );
404        let findings = AgentSpecConformanceControl.evaluate(&b);
405        assert_eq!(findings[0].status, ControlStatus::Satisfied);
406    }
407
408    // 17. normalize_path unit tests
409    #[test]
410    fn normalize_path_resolves_traversal() {
411        assert_eq!(normalize_path("src/../secrets/key.pem"), "secrets/key.pem");
412        assert_eq!(normalize_path("./src/main.rs"), "src/main.rs");
413        assert_eq!(normalize_path("src/./deep/../main.rs"), "src/main.rs");
414        assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
415    }
416
417    // 18. Boundary: steps exactly at limit -> Satisfied
418    #[test]
419    fn steps_at_limit_satisfied() {
420        let b = bundle(
421            spec(vec![], vec![], vec![], Some(100), None),
422            exec(vec![], vec![], 100, 0),
423        );
424        let findings = AgentSpecConformanceControl.evaluate(&b);
425        assert_eq!(findings[0].status, ControlStatus::Satisfied);
426    }
427
428    // 19. Boundary: budget exactly at limit -> Satisfied
429    #[test]
430    fn budget_at_limit_satisfied() {
431        let b = bundle(
432            spec(vec![], vec![], vec![], None, Some(2000)),
433            exec(vec![], vec![], 0, 2000),
434        );
435        let findings = AgentSpecConformanceControl.evaluate(&b);
436        assert_eq!(findings[0].status, ControlStatus::Satisfied);
437    }
438
439    // 20. Boundary: steps one over limit -> Violated
440    #[test]
441    fn steps_one_over_limit_violated() {
442        let b = bundle(
443            spec(vec![], vec![], vec![], Some(100), None),
444            exec(vec![], vec![], 101, 0),
445        );
446        let findings = AgentSpecConformanceControl.evaluate(&b);
447        assert_eq!(findings[0].status, ControlStatus::Violated);
448    }
449
450    // 21. Boundary: budget one over limit -> Violated
451    #[test]
452    fn budget_one_over_limit_violated() {
453        let b = bundle(
454            spec(vec![], vec![], vec![], None, Some(2000)),
455            exec(vec![], vec![], 0, 2001),
456        );
457        let findings = AgentSpecConformanceControl.evaluate(&b);
458        assert_eq!(findings[0].status, ControlStatus::Violated);
459    }
460}