agenteval-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. agenteval/__init__.py +5 -0
  2. agenteval/_starter/certification/policy.yaml +15 -0
  3. agenteval/_starter/certification/systems/northwind-cs-staging.yaml +16 -0
  4. agenteval/_starter/certification/systems/northwind-cs.yaml +17 -0
  5. agenteval/_starter/examples/agents/demo-good.yaml +41 -0
  6. agenteval/_starter/examples/agents/demo-weak.yaml +34 -0
  7. agenteval/_starter/examples/agents/openai-compatible.example.yaml +15 -0
  8. agenteval/_starter/examples/agents/webhook.example.yaml +21 -0
  9. agenteval/_starter/examples/attestations-certification.yaml +16 -0
  10. agenteval/_starter/examples/attestations.yaml +19 -0
  11. agenteval/_starter/examples/snapshots/sample-cluster.json +323 -0
  12. agenteval/_starter/exams/customer-service/cs-ecommerce-v1.yaml +222 -0
  13. agenteval/_starter/rules/industry-pci-dss/i18n/zh.yaml +27 -0
  14. agenteval/_starter/rules/industry-pci-dss/pack.yaml +9 -0
  15. agenteval/_starter/rules/industry-pci-dss/rules.yaml +83 -0
  16. agenteval/_starter/rules/us-ca/i18n/zh.yaml +21 -0
  17. agenteval/_starter/rules/us-ca/pack.yaml +9 -0
  18. agenteval/_starter/rules/us-ca/rules.yaml +72 -0
  19. agenteval/_starter/rules/us-federal/governance.yaml +216 -0
  20. agenteval/_starter/rules/us-federal/i18n/zh.yaml +107 -0
  21. agenteval/_starter/rules/us-federal/pack.yaml +8 -0
  22. agenteval/_starter/rules/us-federal/security.yaml +164 -0
  23. agenteval/certification/__init__.py +16 -0
  24. agenteval/certification/assess.py +137 -0
  25. agenteval/certification/issuer.py +165 -0
  26. agenteval/certification/models.py +113 -0
  27. agenteval/certification/render.py +100 -0
  28. agenteval/certification/signing.py +86 -0
  29. agenteval/cli.py +533 -0
  30. agenteval/collectors/__init__.py +30 -0
  31. agenteval/collectors/kubernetes.py +70 -0
  32. agenteval/collectors/normalize.py +247 -0
  33. agenteval/config.py +49 -0
  34. agenteval/engine/__init__.py +12 -0
  35. agenteval/engine/conditions.py +109 -0
  36. agenteval/engine/evaluator.py +133 -0
  37. agenteval/engine/loader.py +131 -0
  38. agenteval/evaluation/__init__.py +17 -0
  39. agenteval/evaluation/adapters.py +276 -0
  40. agenteval/evaluation/assertions.py +73 -0
  41. agenteval/evaluation/judges.py +214 -0
  42. agenteval/evaluation/messages.py +56 -0
  43. agenteval/evaluation/models.py +174 -0
  44. agenteval/evaluation/papers.py +103 -0
  45. agenteval/evaluation/report.py +80 -0
  46. agenteval/evaluation/runner.py +154 -0
  47. agenteval/i18n.py +118 -0
  48. agenteval/models.py +136 -0
  49. agenteval/reports/__init__.py +86 -0
  50. agenteval/reports/templates/certificate.html.j2 +178 -0
  51. agenteval/reports/templates/eval_report.html.j2 +191 -0
  52. agenteval/reports/templates/report.html.j2 +75 -0
  53. agenteval/schema.py +66 -0
  54. agenteval/schemas/attestations.schema.json +76 -0
  55. agenteval/schemas/exam.schema.json +85 -0
  56. agenteval/schemas/pack.schema.json +48 -0
  57. agenteval/schemas/policy.schema.json +67 -0
  58. agenteval/schemas/rule.schema.json +298 -0
  59. agenteval/schemas/system.schema.json +76 -0
  60. agenteval/schemas/translations.schema.json +32 -0
  61. agenteval_toolkit-0.1.0.dist-info/METADATA +307 -0
  62. agenteval_toolkit-0.1.0.dist-info/RECORD +67 -0
  63. agenteval_toolkit-0.1.0.dist-info/WHEEL +5 -0
  64. agenteval_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  65. agenteval_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  66. agenteval_toolkit-0.1.0.dist-info/licenses/NOTICE +19 -0
  67. agenteval_toolkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,247 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Normalize a snapshot of raw Kubernetes objects into assets and facts (pure functions, easy to test offline).
4
+
5
+ Snapshot format (same as the items field of `kubectl get -o json`, camelCase):
6
+ {
7
+ "cluster": {"name": "prod-gpu-1"},
8
+ "workloads": [Deployment/StatefulSet ...],
9
+ "services": [...], "ingresses": [...], "network_policies": [...]
10
+ }
11
+
12
+ Security note: this module records only the *names* of sensitive environment variables and never writes their values to facts or reports.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from typing import Any
20
+
21
+ from agenteval.models import Asset
22
+
23
+ # Label/annotation prefix for AgentEval hints, e.g. agenteval.io/auth: oidc.
24
+ # Override with AGENTEVAL_ANNOTATION_PREFIX (a trailing "/" is added if missing).
25
+ ANN = os.environ.get("AGENTEVAL_ANNOTATION_PREFIX", "agenteval.io/").rstrip("/") + "/"
26
+
27
+ SERVER_PATTERNS: dict[str, str] = {
28
+ "vllm": r"vllm",
29
+ "tgi": r"text-generation-inference",
30
+ "triton": r"tritonserver",
31
+ "ollama": r"ollama",
32
+ "llamacpp": r"llama[.\-_]?cpp",
33
+ "sglang": r"sglang",
34
+ "ray-serve": r"rayproject/ray",
35
+ "kserve": r"kserve",
36
+ }
37
+ GENERATIVE_SERVERS = {"vllm", "tgi", "ollama", "llamacpp", "sglang"}
38
+ AUTH_SIDECAR = re.compile(r"oauth2-proxy|envoy|kong|authz|istio/proxy", re.I)
39
+ GUARD_SIDECAR = re.compile(r"guard|guardrails|nemo-guardrails", re.I)
40
+ SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)", re.I)
41
+
42
+
43
+ # ---------------- Helpers ----------------
44
+
45
+ def _meta(obj: dict) -> dict:
46
+ return obj.get("metadata") or {}
47
+
48
+
49
+ def _labels_match(selector: dict | None, labels: dict, empty_matches_all: bool) -> bool:
50
+ """Label subset matching.
51
+
52
+ - Service.spec.selector is a flat dict; empty/missing selects no Pods;
53
+ - NetworkPolicy.spec.podSelector is a LabelSelector; {} selects all Pods;
54
+ - selectors with matchExpressions are treated as non-matching for now (conservative; full support in Phase 2).
55
+ """
56
+ if not selector:
57
+ return empty_matches_all and selector is not None
58
+ if selector.get("matchExpressions"):
59
+ return False
60
+ match = (selector.get("matchLabels") or {}) if "matchLabels" in selector else selector
61
+ if not match:
62
+ return empty_matches_all
63
+ return all(labels.get(k) == v for k, v in match.items())
64
+
65
+
66
+ def _image_tag(ref: str) -> str | None:
67
+ if "@" in ref:
68
+ return None
69
+ last = ref.rsplit("/", 1)[-1]
70
+ return last.split(":", 1)[1] if ":" in last else None
71
+
72
+
73
+ def detect_server(images: list[str]) -> str | None:
74
+ for img in images:
75
+ for server, pat in SERVER_PATTERNS.items():
76
+ if re.search(pat, img, re.I):
77
+ return server
78
+ return None
79
+
80
+
81
+ def _model_from_args(container: dict) -> str | None:
82
+ args = (container.get("command") or []) + (container.get("args") or [])
83
+ for i, a in enumerate(args):
84
+ if a in ("--model", "--model-id", "--model_name") and i + 1 < len(args):
85
+ return args[i + 1]
86
+ if a.startswith("--model=") or a.startswith("--model-id="):
87
+ return a.split("=", 1)[1]
88
+ if a == "serve" and i + 1 < len(args) and not args[i + 1].startswith("-"):
89
+ return args[i + 1]
90
+ for e in container.get("env") or []:
91
+ if e.get("name") in ("MODEL_ID", "MODEL_NAME", "MODEL") and e.get("value"):
92
+ return e["value"]
93
+ return None
94
+
95
+
96
+ # ---------------- Main logic ----------------
97
+
98
+ def _workload_to_asset(wl: dict, services: list[dict], ingresses: list[dict],
99
+ netpols: list[dict]) -> Asset | None:
100
+ meta = _meta(wl)
101
+ ns = meta.get("namespace", "default")
102
+ name = meta.get("name", "unknown")
103
+ tpl = (wl.get("spec") or {}).get("template") or {}
104
+ tpl_meta = _meta(tpl)
105
+ pod_spec = tpl.get("spec") or {}
106
+ containers = pod_spec.get("containers") or []
107
+ pod_labels = tpl_meta.get("labels") or {}
108
+ labels = {**(meta.get("labels") or {}), **pod_labels}
109
+ ann = {**(meta.get("annotations") or {}), **(tpl_meta.get("annotations") or {})}
110
+
111
+ images = [c.get("image", "") for c in containers + (pod_spec.get("initContainers") or [])]
112
+ server = detect_server(images)
113
+ if not server and labels.get(ANN + "inference") != "true":
114
+ return None # not an inference workload
115
+
116
+ # --- Authentication ---
117
+ auth_method = ann.get(ANN + "auth")
118
+ auth_enabled = bool(auth_method and auth_method.lower() != "none")
119
+ if not auth_method:
120
+ for c in containers:
121
+ args = (c.get("command") or []) + (c.get("args") or [])
122
+ env_names = {e.get("name") for e in c.get("env") or []}
123
+ if any(a == "--api-key" or a.startswith("--api-key=") for a in args) or \
124
+ env_names & {"VLLM_API_KEY", "API_KEY"}:
125
+ auth_enabled, auth_method = True, "api-key"
126
+ elif AUTH_SIDECAR.search(c.get("image", "")):
127
+ auth_enabled, auth_method = True, "proxy-sidecar"
128
+
129
+ # --- Exposure and TLS ---
130
+ svcs = [s for s in services if _meta(s).get("namespace") == ns
131
+ and _labels_match((s.get("spec") or {}).get("selector"), pod_labels, empty_matches_all=False)]
132
+ svc_names = {_meta(s).get("name") for s in svcs}
133
+ svc_types = sorted({(s.get("spec") or {}).get("type", "ClusterIP") for s in svcs})
134
+ ings = []
135
+ for ing in ingresses:
136
+ if _meta(ing).get("namespace") != ns:
137
+ continue
138
+ backends = set()
139
+ for rule in (ing.get("spec") or {}).get("rules") or []:
140
+ for p in ((rule.get("http") or {}).get("paths")) or []:
141
+ backends.add((((p.get("backend") or {}).get("service")) or {}).get("name"))
142
+ if backends & svc_names:
143
+ ings.append(ing)
144
+ public = bool(ings) or any(t in ("LoadBalancer", "NodePort") for t in svc_types)
145
+ hosts = sorted({r.get("host") for i in ings for r in (i.get("spec") or {}).get("rules") or [] if r.get("host")})
146
+ if ings:
147
+ tls_enabled = all((i.get("spec") or {}).get("tls") for i in ings)
148
+ else:
149
+ tls_enabled = ann.get(ANN + "tls") == "true"
150
+
151
+ # --- NetworkPolicy ---
152
+ np_present = any(
153
+ _meta(np).get("namespace") == ns and _labels_match((np.get("spec") or {}).get("podSelector", {}), pod_labels, empty_matches_all=True)
154
+ for np in netpols
155
+ )
156
+
157
+ # --- Images ---
158
+ main_images = [c.get("image", "") for c in containers]
159
+ digest_pinned = all("@sha256:" in i for i in main_images) if main_images else False
160
+ any_latest = any(_image_tag(i) in (None, "latest") and "@sha256:" not in i for i in main_images)
161
+
162
+ # --- SecurityContext ---
163
+ pod_sc = pod_spec.get("securityContext") or {}
164
+ def _non_root(c):
165
+ sc = c.get("securityContext") or {}
166
+ if sc.get("runAsNonRoot") is True or pod_sc.get("runAsNonRoot") is True:
167
+ return True
168
+ uid = sc.get("runAsUser", pod_sc.get("runAsUser"))
169
+ return isinstance(uid, int) and uid > 0
170
+ run_as_non_root = all(_non_root(c) for c in containers) if containers else False
171
+ privileged = any((c.get("securityContext") or {}).get("privileged") is True for c in containers)
172
+ # Kubernetes allows privilege escalation by default; it must be explicitly set to false
173
+ allow_pe = any((c.get("securityContext") or {}).get("allowPrivilegeEscalation") is not False for c in containers)
174
+
175
+ # --- Plaintext secrets (names only) ---
176
+ plaintext = sorted({
177
+ f"{c.get('name')}:{e.get('name')}"
178
+ for c in containers for e in c.get("env") or []
179
+ if e.get("value") and SECRET_NAME.search(e.get("name", ""))
180
+ })
181
+
182
+ # --- Resource limits ---
183
+ limits_set = all(((c.get("resources") or {}).get("limits") or {}).get("memory") for c in containers) \
184
+ if containers else False
185
+
186
+ # --- Content safety ---
187
+ cs = ann.get(ANN + "content-safety")
188
+ cs_enabled = bool(cs and cs.lower() != "none")
189
+ if not cs:
190
+ cs_enabled = any(GUARD_SIDECAR.search(i) for i in main_images[1:])
191
+ cs = "sidecar" if cs_enabled else None
192
+
193
+ model_name = ann.get(ANN + "model") or next((m for c in containers if (m := _model_from_args(c))), None)
194
+
195
+ facts: dict[str, Any] = {
196
+ "kind": wl.get("kind", "Deployment"),
197
+ "server": server or "custom",
198
+ "replicas": (wl.get("spec") or {}).get("replicas"),
199
+ "owner": labels.get(ANN + "owner") or labels.get("owner") or labels.get("team"),
200
+ "risk_tier": ann.get(ANN + "risk-tier", "standard"),
201
+ "customer_facing": ann.get(ANN + "customer-facing") == "true",
202
+ "ai_disclosure": {"enabled": ann.get(ANN + "ai-disclosure") == "true"},
203
+ "model": {
204
+ "name": model_name,
205
+ "source": ann.get(ANN + "model-source"),
206
+ "card_url": ann.get(ANN + "model-card"),
207
+ "provenance_verified": ann.get(ANN + "provenance-verified") == "true",
208
+ "generative": (server in GENERATIVE_SERVERS) or ann.get(ANN + "generative") == "true",
209
+ "fine_tuned": ann.get(ANN + "fine-tuned") == "true",
210
+ },
211
+ "auth": {"enabled": auth_enabled, "method": auth_method},
212
+ "exposure": {"public": public, "service_types": svc_types, "ingress_hosts": hosts},
213
+ "tls": {"enabled": tls_enabled},
214
+ "network_policy": {"present": np_present},
215
+ "image": {"refs": main_images, "all_digest_pinned": digest_pinned, "any_latest": any_latest},
216
+ "security_context": {
217
+ "run_as_non_root": run_as_non_root,
218
+ "privileged": privileged,
219
+ "allow_privilege_escalation": allow_pe,
220
+ },
221
+ "secrets": {"plaintext_env": plaintext},
222
+ "resources": {"limits_set": limits_set},
223
+ "logging": {"request_logging": ann.get(ANN + "request-logging") == "true",
224
+ "pii_redaction": ann.get(ANN + "log-redaction") == "true"},
225
+ "monitoring": {"enabled": ann.get("prometheus.io/scrape") == "true" or ann.get(ANN + "monitoring") == "true"},
226
+ "content_safety": {"enabled": cs_enabled, "method": cs},
227
+ }
228
+ kind = facts["kind"].lower()
229
+ return Asset(id=f"{kind}/{ns}/{name}", type="inference_service", name=name, namespace=ns, facts=facts)
230
+
231
+
232
+ def normalize_snapshot(snapshot: dict) -> list[Asset]:
233
+ services = snapshot.get("services") or []
234
+ ingresses = snapshot.get("ingresses") or []
235
+ netpols = snapshot.get("network_policies") or []
236
+ assets = [a for wl in snapshot.get("workloads") or []
237
+ if (a := _workload_to_asset(wl, services, ingresses, netpols)) is not None]
238
+
239
+ cluster_name = (snapshot.get("cluster") or {}).get("name", "cluster")
240
+ cluster = Asset(
241
+ id=f"cluster/{cluster_name}", type="cluster", name=cluster_name,
242
+ facts={
243
+ "inference_service_count": len(assets),
244
+ "namespaces": sorted({a.namespace for a in assets if a.namespace}),
245
+ },
246
+ )
247
+ return [cluster, *assets]
agenteval/config.py ADDED
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Runtime configuration and shared loaders."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from agenteval.schema import check
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parent.parent
15
+ # Starter content (rules, exams, certification, examples) copied into the wheel at build time.
16
+ STARTER_ROOT = Path(__file__).resolve().parent / "_starter"
17
+ STARTER_DIRS = ("rules", "exams", "certification", "examples")
18
+
19
+
20
+ def content_dir(name: str, env_var: str) -> Path:
21
+ """Resolve a content directory: $ENV, then ./<name>, then a source checkout, then the bundled starter copy."""
22
+ env = os.environ.get(env_var)
23
+ if env:
24
+ return Path(env)
25
+ for candidate in (Path.cwd() / name, REPO_ROOT / name, STARTER_ROOT / name):
26
+ if candidate.exists():
27
+ return candidate
28
+ return Path.cwd() / name
29
+
30
+
31
+ def starter_root() -> Path:
32
+ """Directory holding the bundled starter content (the wheel copy, or the source checkout)."""
33
+ return STARTER_ROOT if STARTER_ROOT.exists() else REPO_ROOT
34
+
35
+
36
+ def default_rules_dir() -> Path:
37
+ return content_dir("rules", "AGENTEVAL_RULES_DIR")
38
+
39
+
40
+ def load_attestations(path: str | Path | list | None) -> list[dict]:
41
+ """Read one or more attestation files and merge them."""
42
+ if not path:
43
+ return []
44
+ if isinstance(path, (list, tuple)):
45
+ return [a for p in path for a in load_attestations(p)]
46
+ with Path(path).open(encoding="utf-8") as fh:
47
+ data = yaml.safe_load(fh) or {}
48
+ check("attestations", data, str(path))
49
+ return data.get("attestations", []) if isinstance(data, dict) else data
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+
4
+ from .conditions import evaluate, resolve, MISSING, ConditionError
5
+ from .loader import load_packs, load_rules, RuleLoadError, rules_version
6
+ from .evaluator import run_scan, evaluate_rule
7
+
8
+ __all__ = [
9
+ "evaluate", "resolve", "MISSING", "ConditionError",
10
+ "load_packs", "load_rules", "RuleLoadError", "rules_version",
11
+ "run_scan", "evaluate_rule",
12
+ ]
@@ -0,0 +1,109 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Safe condition expression evaluator (does not use eval).
4
+
5
+ Syntax:
6
+ {"all": [cond, ...]} all must hold
7
+ {"any": [cond, ...]} any must hold
8
+ {"not": cond} negation
9
+ {"field": "a.b.c", "op": "eq", "value": ...} leaf condition
10
+
11
+ Missing fields resolve to MISSING. Except for exists/not_exists/falsy/empty, every leaf condition returns False
12
+ for MISSING, i.e. "no evidence means no pass" (fail-closed).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any
19
+
20
+
21
+ class _Missing:
22
+ def __repr__(self) -> str: # pragma: no cover
23
+ return "<missing>"
24
+
25
+ def __bool__(self) -> bool:
26
+ return False
27
+
28
+
29
+ MISSING = _Missing()
30
+
31
+
32
+ class ConditionError(ValueError):
33
+ pass
34
+
35
+
36
+ def resolve(facts: dict[str, Any], path: str) -> Any:
37
+ cur: Any = facts
38
+ for part in path.split("."):
39
+ if isinstance(cur, dict) and part in cur:
40
+ cur = cur[part]
41
+ else:
42
+ return MISSING
43
+ return cur
44
+
45
+
46
+ def _cmp(a: Any, b: Any, fn) -> bool:
47
+ try:
48
+ return bool(fn(a, b))
49
+ except TypeError:
50
+ return False
51
+
52
+
53
+ def _leaf(actual: Any, op: str, expected: Any) -> bool:
54
+ if op == "exists":
55
+ return actual is not MISSING and actual is not None
56
+ if op == "not_exists":
57
+ return actual is MISSING or actual is None
58
+ if op == "falsy":
59
+ return not actual
60
+ if op == "empty":
61
+ return actual is MISSING or actual is None or (hasattr(actual, "__len__") and len(actual) == 0)
62
+ if actual is MISSING:
63
+ return False
64
+ if op == "truthy":
65
+ return bool(actual)
66
+ if op == "not_empty":
67
+ return actual is not None and hasattr(actual, "__len__") and len(actual) > 0
68
+ if op == "eq":
69
+ return actual == expected
70
+ if op == "ne":
71
+ return actual != expected
72
+ if op == "in":
73
+ return isinstance(expected, list) and actual in expected
74
+ if op == "not_in":
75
+ return isinstance(expected, list) and actual not in expected
76
+ if op == "contains":
77
+ return isinstance(actual, (list, str, dict)) and expected in actual
78
+ if op == "not_contains":
79
+ return isinstance(actual, (list, str, dict)) and expected not in actual
80
+ if op == "regex":
81
+ return isinstance(actual, str) and re.search(str(expected), actual) is not None
82
+ if op == "gt":
83
+ return _cmp(actual, expected, lambda a, b: a > b)
84
+ if op == "gte":
85
+ return _cmp(actual, expected, lambda a, b: a >= b)
86
+ if op == "lt":
87
+ return _cmp(actual, expected, lambda a, b: a < b)
88
+ if op == "lte":
89
+ return _cmp(actual, expected, lambda a, b: a <= b)
90
+ raise ConditionError(f"Unknown operator: {op}")
91
+
92
+
93
+ def evaluate(cond: dict[str, Any], facts: dict[str, Any], evidence: dict[str, Any] | None = None) -> bool:
94
+ """Evaluate a condition; if evidence is given, record the fields consulted and their actual values."""
95
+ if "all" in cond:
96
+ # No short-circuiting, so that complete evidence is collected
97
+ results = [evaluate(c, facts, evidence) for c in cond["all"]]
98
+ return all(results)
99
+ if "any" in cond:
100
+ results = [evaluate(c, facts, evidence) for c in cond["any"]]
101
+ return any(results)
102
+ if "not" in cond:
103
+ return not evaluate(cond["not"], facts, evidence)
104
+ if "field" in cond and "op" in cond:
105
+ actual = resolve(facts, cond["field"])
106
+ if evidence is not None:
107
+ evidence[cond["field"]] = None if actual is MISSING else actual
108
+ return _leaf(actual, cond["op"], cond.get("value"))
109
+ raise ConditionError(f"Invalid condition: {cond!r}")
@@ -0,0 +1,133 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Rule evaluation: assets × rules → findings."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from datetime import date, datetime, timezone
9
+ from typing import Any
10
+
11
+ from agenteval.models import (
12
+ Asset, Finding, Rule, RuleType, ScanResult, ScanSummary, Status,
13
+ )
14
+
15
+ from agenteval.i18n import DEFAULT_LANG, format_message
16
+
17
+ from .conditions import ConditionError, evaluate
18
+ from .loader import rules_version
19
+
20
+
21
+ def _match_attestation(rule: Rule, asset: Asset, attestations: list[dict[str, Any]]) -> dict | None:
22
+ """Merge all attestations for this rule that apply to the asset (wildcard "*" and asset-level); asset-level evidence wins.
23
+
24
+ When several attestations are merged, the earliest attested_at is kept (a conservative validity check), so
25
+ manual material and evaluation-generated attestations (e.g. agenteval eval --attest-asset) can live in separate files.
26
+ """
27
+ matched = [a for a in attestations if a.get("rule_id") == rule.id
28
+ and a.get("asset_id", "*") in ("*", asset.id)]
29
+ if not matched:
30
+ return None
31
+ matched.sort(key=lambda a: a.get("asset_id", "*") != "*") # wildcard first, asset-level overrides
32
+ evidence: dict[str, Any] = {}
33
+ dates, people = [], []
34
+ for a in matched:
35
+ evidence.update({k: v for k, v in (a.get("evidence") or {}).items() if v})
36
+ if a.get("attested_at"):
37
+ d = a["attested_at"]
38
+ dates.append(date.fromisoformat(d) if isinstance(d, str) else (d.date() if isinstance(d, datetime) else d))
39
+ if a.get("attested_by"):
40
+ people.append(str(a["attested_by"]))
41
+ return {"evidence": evidence, "attested_at": min(dates) if dates else None,
42
+ "attested_by": ", ".join(dict.fromkeys(people)) or None}
43
+
44
+
45
+ def _evaluate_attestation(rule: Rule, asset: Asset, attestations, today: date) -> tuple[Status, str, dict, dict]:
46
+ """Return (status, message code, message params, evidence)."""
47
+ spec = rule.attestation or {}
48
+ required = spec.get("evidence_required", [])
49
+ att = _match_attestation(rule, asset, attestations)
50
+ if att is None:
51
+ return Status.manual, "att_missing", {"required": required}, {"required": required}
52
+
53
+ evidence = att.get("evidence") or {}
54
+ missing = [k for k in required if not evidence.get(k)]
55
+ ev = {"attested_by": att.get("attested_by"), "attested_at": str(att.get("attested_at")),
56
+ "provided": sorted(evidence.keys())}
57
+ if missing:
58
+ return Status.failed, "att_incomplete", {"missing": missing}, {**ev, "missing": missing}
59
+
60
+ max_age = spec.get("max_age_days")
61
+ attested_at = att.get("attested_at")
62
+ if max_age and attested_at:
63
+ if isinstance(attested_at, str):
64
+ attested_at = date.fromisoformat(attested_at)
65
+ if isinstance(attested_at, datetime):
66
+ attested_at = attested_at.date()
67
+ age = (today - attested_at).days
68
+ ev["age_days"] = age
69
+ if age > max_age:
70
+ return Status.failed, "att_expired", {"age": age, "max_age": max_age}, ev
71
+ elif max_age and not attested_at:
72
+ return Status.failed, "att_no_date", {}, ev
73
+
74
+ return Status.passed, "att_ok", {}, ev
75
+
76
+
77
+ def evaluate_rule(rule: Rule, asset: Asset, attestations: list[dict] | None = None,
78
+ today: date | None = None) -> Finding | None:
79
+ """Return None if the rule does not target this asset type."""
80
+ if asset.type not in rule.applies_to.asset_types:
81
+ return None
82
+ today = today or datetime.now(timezone.utc).date()
83
+ evidence: dict[str, Any] = {}
84
+ params: dict[str, Any] = {}
85
+
86
+ try:
87
+ if rule.applies_to.when is not None and not evaluate(rule.applies_to.when, asset.facts):
88
+ status, code = Status.not_applicable, "not_applicable"
89
+ elif rule.type == RuleType.attestation:
90
+ status, code, params, evidence = _evaluate_attestation(rule, asset, attestations or [], today)
91
+ else:
92
+ ok = evaluate(rule.check or {}, asset.facts, evidence)
93
+ status = Status.passed if ok else Status.failed
94
+ code = "ok" if ok else "check_failed"
95
+ except ConditionError as e:
96
+ status, code, params = Status.error, "eval_error", {"error": str(e)}
97
+
98
+ return Finding(
99
+ rule_id=rule.id, pack=rule.pack, rule_title=rule.title, severity=rule.severity, category=rule.category,
100
+ asset_id=asset.id, asset_name=asset.name, asset_type=asset.type,
101
+ status=status, message=format_message(code, params, DEFAULT_LANG, rule.title),
102
+ message_code=code, message_params=params, evidence=evidence,
103
+ remediation=rule.remediation, sources=rule.sources, translations=rule.translations,
104
+ )
105
+
106
+
107
+ def summarize(findings: list[Finding]) -> ScanSummary:
108
+ s = ScanSummary(total=len(findings))
109
+ for f in findings:
110
+ s.by_status[f.status.value] = s.by_status.get(f.status.value, 0) + 1
111
+ if f.status == Status.failed:
112
+ s.failed_by_severity[f.severity.value] = s.failed_by_severity.get(f.severity.value, 0) + 1
113
+ passed = s.by_status.get("pass", 0)
114
+ failed = s.by_status.get("fail", 0)
115
+ s.score = round(100.0 * passed / (passed + failed), 1) if (passed + failed) else 100.0
116
+ return s
117
+
118
+
119
+ def run_scan(rules: list[Rule], assets: list[Asset], source: str,
120
+ attestations: list[dict] | None = None, today: date | None = None) -> ScanResult:
121
+ started = datetime.now(timezone.utc)
122
+ findings: list[Finding] = []
123
+ for asset in assets:
124
+ for rule in rules:
125
+ f = evaluate_rule(rule, asset, attestations, today)
126
+ if f is not None:
127
+ findings.append(f)
128
+ findings.sort(key=lambda f: (f.status != Status.failed, -f.severity.rank, f.rule_id, f.asset_id))
129
+ return ScanResult(
130
+ scan_id=uuid.uuid4().hex[:12], source=source, started_at=started,
131
+ finished_at=datetime.now(timezone.utc), rules_version=rules_version(rules),
132
+ asset_count=len(assets), findings=findings, summary=summarize(findings),
133
+ )
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The AgentEval Authors
3
+ """Rule loading and validation: YAML → JSON Schema validation → Pydantic models."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from agenteval.models import Rule, RulePack
13
+ from agenteval.schema import errors as schema_errors
14
+
15
+
16
+ class RuleLoadError(Exception):
17
+ def __init__(self, errors: list[str]):
18
+ self.errors = errors
19
+ super().__init__("Failed to load rules:\n " + "\n ".join(errors))
20
+
21
+
22
+ def _iter_rule_docs(path: Path):
23
+ files = sorted(path.rglob("*.y*ml")) if path.is_dir() else [path]
24
+ for f in files:
25
+ if f.suffix not in (".yaml", ".yml") or "i18n" in f.parts or f.name == "pack.yaml":
26
+ continue
27
+ with f.open(encoding="utf-8") as fh:
28
+ data = yaml.safe_load(fh)
29
+ if data is None:
30
+ continue
31
+ # A file may hold a single rule or {"rules": [...]}
32
+ docs = data.get("rules", [data]) if isinstance(data, dict) else data
33
+ for i, doc in enumerate(docs):
34
+ yield f, i, doc
35
+
36
+
37
+ def load_rules(path: str | Path, include_disabled: bool = False) -> list[Rule]:
38
+ path = Path(path)
39
+ if not path.exists():
40
+ raise RuleLoadError([f"Rule path does not exist: {path}"])
41
+
42
+ errors: list[str] = []
43
+ rules: list[Rule] = []
44
+ seen: dict[str, str] = {}
45
+
46
+ for f, i, doc in _iter_rule_docs(path):
47
+ loc = f"{f.name}[{i}]"
48
+ errs = schema_errors("rule", doc, loc)
49
+ if errs:
50
+ errors.extend(errs)
51
+ continue
52
+ rule = Rule.model_validate(doc)
53
+ rule.pack = _pack_for(path, f)
54
+ if rule.id in seen:
55
+ errors.append(f"{loc}: duplicate rule ID {rule.id} (first seen in {seen[rule.id]})")
56
+ continue
57
+ seen[rule.id] = loc
58
+ if rule.enabled or include_disabled:
59
+ rules.append(rule)
60
+
61
+ if not errors:
62
+ errors.extend(_attach_translations(path, rules))
63
+ if errors:
64
+ raise RuleLoadError(errors)
65
+ return sorted(rules, key=lambda r: r.id)
66
+
67
+
68
+ def _pack_for(root: Path, f: Path) -> str:
69
+ """Rule pack a rule belongs to: the nearest pack.yaml found walking up from the file's directory."""
70
+ if not root.is_dir():
71
+ return ""
72
+ d = f.parent
73
+ while True:
74
+ meta = d / "pack.yaml"
75
+ if meta.exists():
76
+ with meta.open(encoding="utf-8") as fh:
77
+ return str((yaml.safe_load(fh) or {}).get("id", d.name))
78
+ if d == root or root not in d.parents:
79
+ return "default"
80
+ d = d.parent
81
+
82
+
83
+ def load_packs(path: str | Path) -> dict[str, RulePack]:
84
+ path = Path(path)
85
+ packs: dict[str, RulePack] = {}
86
+ if path.is_dir():
87
+ for meta in sorted(path.rglob("pack.yaml")):
88
+ with meta.open(encoding="utf-8") as fh:
89
+ data = yaml.safe_load(fh) or {}
90
+ errs = schema_errors("pack", data, str(meta))
91
+ if errs:
92
+ raise RuleLoadError(errs)
93
+ p = RulePack.model_validate(data)
94
+ if p.id in packs:
95
+ raise RuleLoadError([f"{meta}: duplicate pack id {p.id}"])
96
+ packs[p.id] = p
97
+ return packs
98
+
99
+
100
+ def _attach_translations(path: Path, rules: list[Rule]) -> list[str]:
101
+ """Read <rules_dir>/**/i18n/<lang>.yaml and attach translations by rule ID."""
102
+ errors: list[str] = []
103
+ if not path.is_dir():
104
+ return errors
105
+ by_id = {r.id: r for r in rules}
106
+ for f in sorted(path.rglob("i18n/*.y*ml")):
107
+ lang = f.stem
108
+ with f.open(encoding="utf-8") as fh:
109
+ data = yaml.safe_load(fh) or {}
110
+ errs = schema_errors("translations", data, f.name)
111
+ if errs:
112
+ errors.extend(errs)
113
+ continue
114
+ for rid, tr in data.items():
115
+ rule = by_id.get(rid)
116
+ if rule is None:
117
+ continue # Ignore rules that are disabled or belong to another pack
118
+ refs = tr.get("sources") or []
119
+ if len(refs) > len(rule.sources):
120
+ errors.append(f"{f.name}: {rid} has more translated sources than the original rule")
121
+ continue
122
+ rule.translations[lang] = tr
123
+ return errors
124
+
125
+
126
+ def rules_version(rules: list[Rule]) -> str:
127
+ """Rule set fingerprint, used in reports to trace which rule version a scan used."""
128
+ h = hashlib.sha256()
129
+ for r in sorted(rules, key=lambda r: r.id):
130
+ h.update(f"{r.id}@{r.version}".encode())
131
+ return h.hexdigest()[:12]