1#![deny(warnings)]
6
7use std::collections::HashSet;
8
9use crate::catalog::store::CatalogStore;
10use crate::federation::scanner::InstalledAsset;
11use crate::models::agent::Lifecycle;
12use crate::models::policy::{PolicyEvaluation, PolicyRuleType, PolicyScope, RuleResult};
13use crate::models::workspace::ResolvedWorkspace;
14
15use super::parser::PolicyConfig;
16
17pub fn glob_match(pattern: &str, text: &str) -> bool {
24 let pat: Vec<char> = pattern.chars().collect();
25 let txt: Vec<char> = text.chars().collect();
26 glob_inner(&pat, &txt)
27}
28
29fn glob_inner(pat: &[char], txt: &[char]) -> bool {
30 match (pat.first(), txt.first()) {
31 (None, None) => true,
32 (Some(&'*'), _) => {
33 glob_inner(&pat[1..], txt) || (!txt.is_empty() && glob_inner(pat, &txt[1..]))
34 }
35 (Some(&'?'), Some(_)) => glob_inner(&pat[1..], &txt[1..]),
36 (Some(p), Some(t)) if p == t => glob_inner(&pat[1..], &txt[1..]),
37 _ => false,
38 }
39}
40
41pub fn round_half_up_1dp(v: f64) -> f64 {
47 (v * 10.0 + 0.5).floor() / 10.0
48}
49
50pub fn lifecycle_rank(lc: Lifecycle) -> u8 {
59 match lc {
60 Lifecycle::Experimental => 0,
61 Lifecycle::Beta => 1,
62 Lifecycle::Stable => 2,
63 Lifecycle::Deprecated => 3,
64 }
65}
66
67pub fn lifecycle_below(lc: Lifecycle, min_stage: Lifecycle) -> bool {
69 lifecycle_rank(lc) < lifecycle_rank(min_stage)
70}
71
72pub struct PolicyEngine;
78
79impl PolicyEngine {
80 pub fn evaluate(
86 config: &PolicyConfig,
87 workspace: &ResolvedWorkspace,
88 installed: &[InstalledAsset],
89 catalog: &CatalogStore,
90 now_date: &str,
91 ) -> PolicyEvaluation {
92 let mut results: Vec<RuleResult> = Vec::new();
93
94 let installed_ids: HashSet<&str> = installed
95 .iter()
96 .filter(|a| a.confirmed)
97 .map(|a| a.asset_id.as_str())
98 .collect();
99
100 for rule in &config.rules {
101 if !Self::rule_applies(rule, workspace) {
102 continue;
103 }
104 if Self::is_suppressed(rule, workspace, config, now_date) {
105 continue;
106 }
107
108 let (passed, details) =
109 Self::evaluate_rule_type(&rule.rule_type, installed, &installed_ids, catalog);
110
111 results.push(RuleResult {
112 rule_id: rule.id.clone(),
113 passed,
114 details: if passed { None } else { details },
115 });
116 }
117
118 let total = results.len();
119 let passed = results.iter().filter(|r| r.passed).count();
120 let compliance_score = Self::compliance_score(passed, total);
121
122 PolicyEvaluation {
123 workspace: workspace.name.clone(),
124 results,
125 compliance_score,
126 }
127 }
128
129 pub fn rule_applies(
131 rule: &crate::models::policy::PolicyRule,
132 workspace: &ResolvedWorkspace,
133 ) -> bool {
134 match &rule.scope {
135 PolicyScope::All => true,
136 PolicyScope::NamePattern(pat) => glob_match(pat, &workspace.name),
137 PolicyScope::Team(team) => workspace
138 .team
139 .as_deref()
140 .map(|t| t == team.as_str())
141 .unwrap_or(false),
142 }
143 }
144
145 pub fn is_suppressed(
150 rule: &crate::models::policy::PolicyRule,
151 workspace: &ResolvedWorkspace,
152 config: &PolicyConfig,
153 now_date: &str,
154 ) -> bool {
155 config.suppressions.iter().any(|s| {
156 s.rule_id == rule.id
157 && s.workspace == workspace.name
158 && (s.expires.is_empty() || s.expires.as_str() >= now_date)
159 })
160 }
161
162 pub fn compliance_score(passed: usize, total: usize) -> f64 {
165 if total == 0 {
166 return 100.0;
167 }
168 let raw = (passed as f64 / total as f64) * 100.0;
169 round_half_up_1dp(raw)
170 }
171
172 fn evaluate_rule_type(
177 rule_type: &PolicyRuleType,
178 installed: &[InstalledAsset],
179 installed_ids: &HashSet<&str>,
180 catalog: &CatalogStore,
181 ) -> (bool, Option<String>) {
182 match rule_type {
183 PolicyRuleType::RequireAsset { asset_id } => {
184 if installed_ids.contains(asset_id.as_str()) {
185 (true, None)
186 } else {
187 (
188 false,
189 Some(format!("asset '{}' is not installed", asset_id)),
190 )
191 }
192 }
193
194 PolicyRuleType::RequireRole { role_id } => {
195 let role_agents = catalog.agents_for_role(role_id);
196 if role_agents.is_empty() {
197 return (true, None);
198 }
199 let missing: Vec<&str> = role_agents
200 .iter()
201 .filter(|a| !installed_ids.contains(a.id.as_str()))
202 .map(|a| a.id.as_str())
203 .collect();
204 if missing.is_empty() {
205 (true, None)
206 } else {
207 (
208 false,
209 Some(format!(
210 "role '{}' agents not installed: {}",
211 role_id,
212 missing.join(", ")
213 )),
214 )
215 }
216 }
217
218 PolicyRuleType::MaxStale { threshold } => {
219 let stale_count: usize = installed
220 .iter()
221 .filter(|a| is_asset_stale(a, catalog))
222 .count();
223 let thr = *threshold as usize;
224 if stale_count <= thr {
225 (true, None)
226 } else {
227 (
228 false,
229 Some(format!(
230 "{} stale asset(s) exceed threshold of {}",
231 stale_count, thr
232 )),
233 )
234 }
235 }
236
237 PolicyRuleType::TrustBoundary {
238 max_mutation,
239 max_egress,
240 max_credentials,
241 } => {
242 let violations: Vec<String> = catalog
243 .mcp_refs
244 .iter()
245 .filter(|mcp| installed_ids.contains(mcp.id.as_str()))
246 .filter_map(|mcp| {
247 let tm = mcp.trust_matrix.as_ref()?;
248 let mut problems = Vec::new();
249 if !max_mutation && tm.mutation_capable {
250 problems.push("mutation_capable");
251 }
252 if !max_egress && tm.requires_egress {
253 problems.push("requires_egress");
254 }
255 if !max_credentials && tm.requires_credentials {
256 problems.push("requires_credentials");
257 }
258 if problems.is_empty() {
259 None
260 } else {
261 Some(format!("{}: {}", mcp.id, problems.join(",")))
262 }
263 })
264 .collect();
265
266 if violations.is_empty() {
267 (true, None)
268 } else {
269 (
270 false,
271 Some(format!(
272 "trust boundary violated by MCP refs: {}",
273 violations.join("; ")
274 )),
275 )
276 }
277 }
278
279 PolicyRuleType::LifecycleGate { min_stage } => {
280 let violations: Vec<String> = installed
281 .iter()
282 .filter_map(|a| {
283 let agent = catalog.agent_by_id(&a.asset_id)?;
284 let lc = agent.lifecycle?;
285 if lifecycle_below(lc, *min_stage) {
286 Some(format!("{} ({})", a.asset_id, lc))
287 } else {
288 None
289 }
290 })
291 .collect();
292
293 if violations.is_empty() {
294 (true, None)
295 } else {
296 (
297 false,
298 Some(format!(
299 "assets below min lifecycle '{}': {}",
300 min_stage,
301 violations.join(", ")
302 )),
303 )
304 }
305 }
306 }
307 }
308}
309
310fn is_asset_stale(installed: &InstalledAsset, catalog: &CatalogStore) -> bool {
315 let agent = match catalog.agent_by_id(&installed.asset_id) {
316 Some(a) => a,
317 None => return false,
318 };
319 let canonical_ver = match &agent.version {
320 Some(v) => v,
321 None => return false,
322 };
323 let installed_ver = match &installed.installed_version {
324 Some(v) => v,
325 None => return false,
326 };
327 installed_ver != canonical_ver
328}
329
330#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::federation::scanner::InstalledAsset;
338 use crate::models::agent::{AgentType, Lifecycle};
339 use crate::models::harness::{Harness, SourceType};
340 use crate::models::policy::{PolicyRule, PolicyScope, Severity, Suppression};
341 use crate::models::provider::Provider;
342 use crate::models::workspace::{ResolvedWorkspace, WorkspaceStatus};
343 use std::collections::HashMap;
344 use std::path::PathBuf;
345
346 fn make_workspace(name: &str, team: Option<&str>) -> ResolvedWorkspace {
347 ResolvedWorkspace {
348 canonical_path: PathBuf::from(format!("/workspaces/{}", name)),
349 name: name.to_string(),
350 team: team.map(|t| t.to_string()),
351 tags: vec![],
352 status: WorkspaceStatus::Available,
353 }
354 }
355
356 fn make_rule(id: &str, rule_type: PolicyRuleType, scope: PolicyScope) -> PolicyRule {
357 PolicyRule {
358 id: id.to_string(),
359 rule_type,
360 severity: Severity::Warning,
361 scope,
362 description: "test".to_string(),
363 }
364 }
365
366 fn make_installed(asset_id: &str) -> InstalledAsset {
367 InstalledAsset {
368 workspace_path: PathBuf::from(format!("/workspace/.claude/{}.md", asset_id)),
369 asset_id: asset_id.to_string(),
370 installed_version: Some("1.0.0".to_string()),
371 content_hash: "abc".to_string(),
372 detection_methods: vec![],
373 confirmed: true,
374 harness: ".claude".to_string(),
375 }
376 }
377
378 fn make_agent(id: &str, lc: Lifecycle) -> crate::models::agent::Agent {
379 crate::models::agent::Agent {
380 id: id.to_string(),
381 name: id.to_string(),
382 entity_type: AgentType::Agent,
383 provider: Provider::Aws,
384 harnesses: vec![Harness::ClaudeCode],
385 summary: String::new(),
386 source_type: SourceType::Original,
387 official_docs: vec![],
388 security_notes: String::new(),
389 last_verified: "2024-01-01".to_string(),
390 path: format!("agents/{}.md", id),
391 companion_skills: vec![],
392 execution_tier: None,
393 lifecycle: Some(lc),
394 harness_variants: HashMap::new(),
395 author: None,
396 version: Some("1.0.0".to_string()),
397 provider_coverage: None,
398 }
399 }
400
401 fn empty_config() -> PolicyConfig {
402 PolicyConfig::default()
403 }
404
405 fn config_with_rule(rule: PolicyRule) -> PolicyConfig {
406 PolicyConfig {
407 rules: vec![rule],
408 ..Default::default()
409 }
410 }
411
412 #[test]
414 fn glob_match_exact() {
415 assert!(glob_match("production-infra", "production-infra"));
416 assert!(!glob_match("production-infra", "staging-infra"));
417 }
418
419 #[test]
420 fn glob_match_star() {
421 assert!(glob_match("production-*", "production-infra"));
422 assert!(!glob_match("production-*", "staging-infra"));
423 assert!(glob_match("*", "anything"));
424 assert!(glob_match("*", ""));
425 }
426
427 #[test]
428 fn glob_match_question() {
429 assert!(glob_match("prod?", "proda"));
430 assert!(!glob_match("prod?", "prod"));
431 }
432
433 #[test]
435 fn compliance_score_all_pass() {
436 assert!((PolicyEngine::compliance_score(3, 3) - 100.0).abs() < 1e-9);
437 }
438
439 #[test]
440 fn compliance_score_none_pass() {
441 assert!((PolicyEngine::compliance_score(0, 3) - 0.0).abs() < 1e-9);
442 }
443
444 #[test]
445 fn compliance_score_zero_total() {
446 assert!((PolicyEngine::compliance_score(0, 0) - 100.0).abs() < 1e-9);
447 }
448
449 #[test]
450 fn compliance_score_rounds_half_up() {
451 let s = PolicyEngine::compliance_score(1, 3);
452 assert!((s - 33.3).abs() < 1e-9, "got {}", s);
453 let s2 = PolicyEngine::compliance_score(2, 3);
454 assert!((s2 - 66.7).abs() < 1e-9, "got {}", s2);
455 }
456
457 #[test]
459 fn rule_applies_all() {
460 let ws = make_workspace("any-workspace", None);
461 let rule = make_rule(
462 "r1",
463 PolicyRuleType::MaxStale { threshold: 5 },
464 PolicyScope::All,
465 );
466 assert!(PolicyEngine::rule_applies(&rule, &ws));
467 }
468
469 #[test]
470 fn rule_applies_name_pattern_match() {
471 let ws = make_workspace("production-infra", None);
472 let rule = make_rule(
473 "r1",
474 PolicyRuleType::MaxStale { threshold: 5 },
475 PolicyScope::NamePattern("production-*".to_string()),
476 );
477 assert!(PolicyEngine::rule_applies(&rule, &ws));
478 }
479
480 #[test]
481 fn rule_applies_name_pattern_no_match() {
482 let ws = make_workspace("staging-infra", None);
483 let rule = make_rule(
484 "r1",
485 PolicyRuleType::MaxStale { threshold: 5 },
486 PolicyScope::NamePattern("production-*".to_string()),
487 );
488 assert!(!PolicyEngine::rule_applies(&rule, &ws));
489 }
490
491 #[test]
492 fn rule_applies_team_match() {
493 let ws = make_workspace("ws", Some("platform"));
494 let rule = make_rule(
495 "r1",
496 PolicyRuleType::MaxStale { threshold: 5 },
497 PolicyScope::Team("platform".to_string()),
498 );
499 assert!(PolicyEngine::rule_applies(&rule, &ws));
500 }
501
502 #[test]
503 fn rule_applies_team_no_match() {
504 let ws = make_workspace("ws", Some("devex"));
505 let rule = make_rule(
506 "r1",
507 PolicyRuleType::MaxStale { threshold: 5 },
508 PolicyScope::Team("platform".to_string()),
509 );
510 assert!(!PolicyEngine::rule_applies(&rule, &ws));
511 }
512
513 #[test]
515 fn is_suppressed_active() {
516 let ws = make_workspace("staging-infra", None);
517 let rule = make_rule(
518 "no-mutation",
519 PolicyRuleType::MaxStale { threshold: 0 },
520 PolicyScope::All,
521 );
522 let mut cfg = empty_config();
523 cfg.suppressions.push(Suppression {
524 rule_id: "no-mutation".to_string(),
525 workspace: "staging-infra".to_string(),
526 reason: "approved".to_string(),
527 approver: "lead@example.com".to_string(),
528 expires: "2099-12-31".to_string(),
529 });
530 assert!(PolicyEngine::is_suppressed(&rule, &ws, &cfg, "2026-06-13"));
531 }
532
533 #[test]
534 fn is_suppressed_expired() {
535 let ws = make_workspace("staging-infra", None);
536 let rule = make_rule(
537 "no-mutation",
538 PolicyRuleType::MaxStale { threshold: 0 },
539 PolicyScope::All,
540 );
541 let mut cfg = empty_config();
542 cfg.suppressions.push(Suppression {
543 rule_id: "no-mutation".to_string(),
544 workspace: "staging-infra".to_string(),
545 reason: "approved".to_string(),
546 approver: "lead@example.com".to_string(),
547 expires: "2020-01-01".to_string(),
548 });
549 assert!(!PolicyEngine::is_suppressed(&rule, &ws, &cfg, "2026-06-13"));
550 }
551
552 #[test]
554 fn evaluate_require_asset_pass() {
555 let ws = make_workspace("prod", None);
556 let installed = vec![make_installed("aws-iam-scanner")];
557 let rule = make_rule(
558 "r1",
559 PolicyRuleType::RequireAsset {
560 asset_id: "aws-iam-scanner".to_string(),
561 },
562 PolicyScope::All,
563 );
564 let cfg = config_with_rule(rule);
565 let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
566 let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
567 assert_eq!(eval.results.len(), 1);
568 assert!(eval.results[0].passed);
569 assert!((eval.compliance_score - 100.0).abs() < 1e-9);
570 }
571
572 #[test]
573 fn evaluate_require_asset_fail() {
574 let ws = make_workspace("prod", None);
575 let installed: Vec<InstalledAsset> = vec![];
576 let rule = make_rule(
577 "r1",
578 PolicyRuleType::RequireAsset {
579 asset_id: "aws-iam-scanner".to_string(),
580 },
581 PolicyScope::All,
582 );
583 let cfg = config_with_rule(rule);
584 let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
585 let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
586 assert!(!eval.results[0].passed);
587 assert!((eval.compliance_score - 0.0).abs() < 1e-9);
588 }
589
590 #[test]
591 fn evaluate_lifecycle_gate_fail_experimental() {
592 let ws = make_workspace("prod", None);
593 let installed = vec![make_installed("my-agent")];
594 let agent = make_agent("my-agent", Lifecycle::Experimental);
595 let rule = make_rule(
596 "lc",
597 PolicyRuleType::LifecycleGate {
598 min_stage: Lifecycle::Stable,
599 },
600 PolicyScope::All,
601 );
602 let cfg = config_with_rule(rule);
603 let store = CatalogStore::from_parts(vec![agent], vec![], HashMap::new(), vec![], vec![]);
604 let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
605 assert!(!eval.results[0].passed);
606 }
607
608 #[test]
609 fn evaluate_lifecycle_gate_pass_deprecated() {
610 let ws = make_workspace("prod", None);
612 let installed = vec![make_installed("my-agent")];
613 let agent = make_agent("my-agent", Lifecycle::Deprecated);
614 let rule = make_rule(
615 "lc",
616 PolicyRuleType::LifecycleGate {
617 min_stage: Lifecycle::Stable,
618 },
619 PolicyScope::All,
620 );
621 let cfg = config_with_rule(rule);
622 let store = CatalogStore::from_parts(vec![agent], vec![], HashMap::new(), vec![], vec![]);
623 let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
624 assert!(eval.results[0].passed);
625 }
626
627 #[test]
628 fn evaluate_scope_filters_non_applicable() {
629 let ws = make_workspace("staging-infra", None);
630 let installed: Vec<InstalledAsset> = vec![];
631 let rule = make_rule(
632 "r1",
633 PolicyRuleType::RequireAsset {
634 asset_id: "scanner".to_string(),
635 },
636 PolicyScope::NamePattern("production-*".to_string()),
637 );
638 let cfg = config_with_rule(rule);
639 let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
640 let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
641 assert!(eval.results.is_empty());
642 assert!((eval.compliance_score - 100.0).abs() < 1e-9);
643 }
644
645 proptest::proptest! {
650 #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
651
652 #[test]
653 fn prop22_evaluate_deterministic(
654 ws_name in "[a-z][a-z0-9-]{1,15}",
655 asset_id in "[a-z][a-z0-9-]{1,10}",
656 installed_count in 0usize..5,
657 ) {
658 let ws = make_workspace(&ws_name, None);
659 let installed: Vec<InstalledAsset> = (0..installed_count)
660 .map(|i| make_installed(&format!("{}-{}", asset_id, i)))
661 .collect();
662 let rule = make_rule(
663 "r1",
664 PolicyRuleType::RequireAsset { asset_id: asset_id.clone() },
665 PolicyScope::All,
666 );
667 let cfg = config_with_rule(rule);
668 let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
669
670 let eval1 = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
671 let eval2 = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
672
673 proptest::prop_assert_eq!(eval1.workspace.clone(), eval2.workspace.clone());
674 proptest::prop_assert_eq!(eval1.results.len(), eval2.results.len());
675 for (r1, r2) in eval1.results.iter().zip(eval2.results.iter()) {
676 proptest::prop_assert_eq!(r1.rule_id.clone(), r2.rule_id.clone());
677 proptest::prop_assert_eq!(r1.passed, r2.passed);
678 }
679 proptest::prop_assert_eq!(
680 (eval1.compliance_score * 10.0).round() as i64,
681 (eval2.compliance_score * 10.0).round() as i64
682 );
683 }
684
685 #[test]
690 fn prop23_scope_matching(
691 ws_name in "[a-z][a-z0-9-]{1,12}",
692 team in proptest::option::of("[a-z][a-z0-9-]{1,8}"),
693 ) {
694 let ws = ResolvedWorkspace {
695 canonical_path: std::path::PathBuf::from("/ws"),
696 name: ws_name.clone(),
697 team: team.clone(),
698 tags: vec![],
699 status: WorkspaceStatus::Available,
700 };
701
702 let rule_all = make_rule("all", PolicyRuleType::MaxStale { threshold: 99 }, PolicyScope::All);
704 proptest::prop_assert!(PolicyEngine::rule_applies(&rule_all, &ws));
705
706 let rule_exact = make_rule(
708 "exact",
709 PolicyRuleType::MaxStale { threshold: 99 },
710 PolicyScope::NamePattern(ws_name.clone()),
711 );
712 proptest::prop_assert!(PolicyEngine::rule_applies(&rule_exact, &ws));
713
714 let rule_star = make_rule(
716 "star",
717 PolicyRuleType::MaxStale { threshold: 99 },
718 PolicyScope::NamePattern("*".to_string()),
719 );
720 proptest::prop_assert!(PolicyEngine::rule_applies(&rule_star, &ws));
721
722 let sentinel = "sentinel-team-zzz";
724 let rule_team = make_rule(
725 "team",
726 PolicyRuleType::MaxStale { threshold: 99 },
727 PolicyScope::Team(sentinel.to_string()),
728 );
729 let expected = ws.team.as_deref() == Some(sentinel);
730 proptest::prop_assert_eq!(PolicyEngine::rule_applies(&rule_team, &ws), expected);
731 }
732 }
733}