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            forbidden_mcp_servers: Vec::new(),
169        }
170    }
171
172    fn exec(files: Vec<&str>, tools: Vec<&str>, steps: u32, cost: u32) -> AgentExecution {
173        AgentExecution {
174            agent_id: "agent-1".to_string(),
175            session_id: "session-1".to_string(),
176            files_touched: files.into_iter().map(String::from).collect(),
177            tools_used: tools.into_iter().map(String::from).collect(),
178            steps_taken: steps,
179            cost_cents: cost,
180        }
181    }
182
183    fn bundle(s: AgentSpec, e: AgentExecution) -> EvidenceBundle {
184        EvidenceBundle {
185            agent_spec: EvidenceState::complete(s),
186            agent_execution: EvidenceState::complete(e),
187            ..Default::default()
188        }
189    }
190
191    // 1. All checks pass
192    #[test]
193    fn all_checks_pass() {
194        let b = bundle(
195            spec(
196                vec!["src/*"],
197                vec![".env"],
198                vec!["cargo"],
199                Some(100),
200                Some(2000),
201            ),
202            exec(vec!["src/main.rs"], vec!["cargo"], 50, 1000),
203        );
204        let findings = AgentSpecConformanceControl.evaluate(&b);
205        assert_eq!(findings[0].status, ControlStatus::Satisfied);
206    }
207
208    // 2. Touch forbidden path ".env"
209    #[test]
210    fn forbidden_path_exact() {
211        let b = bundle(
212            spec(vec![], vec![".env"], vec![], None, None),
213            exec(vec![".env"], vec![], 0, 0),
214        );
215        let findings = AgentSpecConformanceControl.evaluate(&b);
216        assert_eq!(findings[0].status, ControlStatus::Violated);
217        assert!(findings[0].subjects.iter().any(|s| s.contains(".env")));
218    }
219
220    // 3. Touch file not in allowed_paths
221    #[test]
222    fn file_not_in_allowed_paths() {
223        let b = bundle(
224            spec(vec!["src/*"], vec![], vec![], None, None),
225            exec(vec!["config/settings.toml"], vec![], 0, 0),
226        );
227        let findings = AgentSpecConformanceControl.evaluate(&b);
228        assert_eq!(findings[0].status, ControlStatus::Violated);
229        assert!(
230            findings[0]
231                .subjects
232                .iter()
233                .any(|s| s.contains("config/settings.toml"))
234        );
235    }
236
237    // 4. Use unauthorized tool
238    #[test]
239    fn unauthorized_tool() {
240        let b = bundle(
241            spec(vec![], vec![], vec!["cargo"], None, None),
242            exec(vec![], vec!["curl"], 0, 0),
243        );
244        let findings = AgentSpecConformanceControl.evaluate(&b);
245        assert_eq!(findings[0].status, ControlStatus::Violated);
246        assert!(findings[0].subjects.iter().any(|s| s.contains("curl")));
247    }
248
249    // 5. Exceed step limit
250    #[test]
251    fn exceed_step_limit() {
252        let b = bundle(
253            spec(vec![], vec![], vec![], Some(100), None),
254            exec(vec![], vec![], 150, 0),
255        );
256        let findings = AgentSpecConformanceControl.evaluate(&b);
257        assert_eq!(findings[0].status, ControlStatus::Violated);
258        assert!(findings[0].subjects.iter().any(|s| s.contains("150/100")));
259    }
260
261    // 6. Exceed budget
262    #[test]
263    fn exceed_budget() {
264        let b = bundle(
265            spec(vec![], vec![], vec![], None, Some(2000)),
266            exec(vec![], vec![], 0, 5000),
267        );
268        let findings = AgentSpecConformanceControl.evaluate(&b);
269        assert_eq!(findings[0].status, ControlStatus::Violated);
270        assert!(findings[0].subjects.iter().any(|s| s.contains("5000/2000")));
271    }
272
273    // 7. Multiple violations at once
274    #[test]
275    fn multiple_violations() {
276        let b = bundle(
277            spec(
278                vec!["src/*"],
279                vec![".env"],
280                vec!["cargo"],
281                Some(100),
282                Some(2000),
283            ),
284            exec(vec![".env", "docs/readme.md"], vec!["curl"], 150, 5000),
285        );
286        let findings = AgentSpecConformanceControl.evaluate(&b);
287        assert_eq!(findings[0].status, ControlStatus::Violated);
288        let subjects = &findings[0].subjects;
289        assert!(subjects.iter().any(|s| s.contains(".env")));
290        assert!(subjects.iter().any(|s| s.contains("docs/readme.md")));
291        assert!(subjects.iter().any(|s| s.contains("curl")));
292        assert!(subjects.iter().any(|s| s.contains("150/100")));
293        assert!(subjects.iter().any(|s| s.contains("5000/2000")));
294    }
295
296    // 8. Empty allowed_paths means no restriction
297    #[test]
298    fn empty_allowed_paths_no_restriction() {
299        let b = bundle(
300            spec(vec![], vec![], vec![], None, None),
301            exec(vec!["anywhere/file.txt"], vec![], 0, 0),
302        );
303        let findings = AgentSpecConformanceControl.evaluate(&b);
304        assert_eq!(findings[0].status, ControlStatus::Satisfied);
305    }
306
307    // 9. Empty allowed_tools means no restriction
308    #[test]
309    fn empty_allowed_tools_no_restriction() {
310        let b = bundle(
311            spec(vec![], vec![], vec![], None, None),
312            exec(vec![], vec!["anything"], 0, 0),
313        );
314        let findings = AgentSpecConformanceControl.evaluate(&b);
315        assert_eq!(findings[0].status, ControlStatus::Satisfied);
316    }
317
318    // 10. Prefix match: forbidden "secrets/" matches "secrets/api.key"
319    #[test]
320    fn forbidden_prefix_match_with_slash() {
321        let b = bundle(
322            spec(vec![], vec!["secrets/"], vec![], None, None),
323            exec(vec!["secrets/api.key"], vec![], 0, 0),
324        );
325        let findings = AgentSpecConformanceControl.evaluate(&b);
326        assert_eq!(findings[0].status, ControlStatus::Violated);
327        assert!(
328            findings[0]
329                .subjects
330                .iter()
331                .any(|s| s.contains("secrets/api.key"))
332        );
333    }
334
335    // 11. Wildcard match: allowed "src/*" matches "src/main.rs"
336    #[test]
337    fn allowed_wildcard_match() {
338        let b = bundle(
339            spec(vec!["src/*"], vec![], vec![], None, None),
340            exec(vec!["src/main.rs"], vec![], 0, 0),
341        );
342        let findings = AgentSpecConformanceControl.evaluate(&b);
343        assert_eq!(findings[0].status, ControlStatus::Satisfied);
344    }
345
346    // 12. Missing spec evidence -> Indeterminate
347    #[test]
348    fn missing_spec_indeterminate() {
349        let b = EvidenceBundle {
350            agent_spec: EvidenceState::missing(vec![]),
351            agent_execution: EvidenceState::complete(exec(vec![], vec![], 0, 0)),
352            ..Default::default()
353        };
354        let findings = AgentSpecConformanceControl.evaluate(&b);
355        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
356    }
357
358    // 13. NotApplicable execution -> NotApplicable
359    #[test]
360    fn not_applicable_execution() {
361        let b = EvidenceBundle {
362            agent_spec: EvidenceState::complete(spec(vec![], vec![], vec![], None, None)),
363            agent_execution: EvidenceState::not_applicable(),
364            ..Default::default()
365        };
366        let findings = AgentSpecConformanceControl.evaluate(&b);
367        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
368    }
369
370    // 14. Path traversal attack: src/../secrets/key.pem should NOT pass allowed "src/*"
371    #[test]
372    fn path_traversal_blocked() {
373        let b = bundle(
374            spec(vec!["src/*"], vec![], vec![], None, None),
375            exec(vec!["src/../secrets/key.pem"], vec![], 0, 0),
376        );
377        let findings = AgentSpecConformanceControl.evaluate(&b);
378        assert_eq!(findings[0].status, ControlStatus::Violated);
379        assert!(
380            findings[0]
381                .subjects
382                .iter()
383                .any(|s| s.contains("secrets/key.pem"))
384        );
385    }
386
387    // 15. Path traversal attack: src/../.env should match forbidden ".env"
388    #[test]
389    fn path_traversal_forbidden_detected() {
390        let b = bundle(
391            spec(vec![], vec![".env"], vec![], None, None),
392            exec(vec!["src/../.env"], vec![], 0, 0),
393        );
394        let findings = AgentSpecConformanceControl.evaluate(&b);
395        assert_eq!(findings[0].status, ControlStatus::Violated);
396    }
397
398    // 16. Normalized path: ./src/main.rs should match allowed "src/*"
399    #[test]
400    fn dot_prefix_normalized() {
401        let b = bundle(
402            spec(vec!["src/*"], vec![], vec![], None, None),
403            exec(vec!["./src/main.rs"], vec![], 0, 0),
404        );
405        let findings = AgentSpecConformanceControl.evaluate(&b);
406        assert_eq!(findings[0].status, ControlStatus::Satisfied);
407    }
408
409    // 17. normalize_path unit tests
410    #[test]
411    fn normalize_path_resolves_traversal() {
412        assert_eq!(normalize_path("src/../secrets/key.pem"), "secrets/key.pem");
413        assert_eq!(normalize_path("./src/main.rs"), "src/main.rs");
414        assert_eq!(normalize_path("src/./deep/../main.rs"), "src/main.rs");
415        assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
416    }
417
418    // 18. Boundary: steps exactly at limit -> Satisfied
419    #[test]
420    fn steps_at_limit_satisfied() {
421        let b = bundle(
422            spec(vec![], vec![], vec![], Some(100), None),
423            exec(vec![], vec![], 100, 0),
424        );
425        let findings = AgentSpecConformanceControl.evaluate(&b);
426        assert_eq!(findings[0].status, ControlStatus::Satisfied);
427    }
428
429    // 19. Boundary: budget exactly at limit -> Satisfied
430    #[test]
431    fn budget_at_limit_satisfied() {
432        let b = bundle(
433            spec(vec![], vec![], vec![], None, Some(2000)),
434            exec(vec![], vec![], 0, 2000),
435        );
436        let findings = AgentSpecConformanceControl.evaluate(&b);
437        assert_eq!(findings[0].status, ControlStatus::Satisfied);
438    }
439
440    // 20. Boundary: steps one over limit -> Violated
441    #[test]
442    fn steps_one_over_limit_violated() {
443        let b = bundle(
444            spec(vec![], vec![], vec![], Some(100), None),
445            exec(vec![], vec![], 101, 0),
446        );
447        let findings = AgentSpecConformanceControl.evaluate(&b);
448        assert_eq!(findings[0].status, ControlStatus::Violated);
449    }
450
451    // 21. Boundary: budget one over limit -> Violated
452    #[test]
453    fn budget_one_over_limit_violated() {
454        let b = bundle(
455            spec(vec![], vec![], vec![], None, Some(2000)),
456            exec(vec![], vec![], 0, 2001),
457        );
458        let findings = AgentSpecConformanceControl.evaluate(&b);
459        assert_eq!(findings[0].status, ControlStatus::Violated);
460    }
461}