1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{AgentExecution, AgentSpec, EvidenceBundle, EvidenceState};
3
4pub struct AgentSpecConformanceControl;
5
6fn 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
22fn 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}