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 }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}