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.
- agenteval/__init__.py +5 -0
- agenteval/_starter/certification/policy.yaml +15 -0
- agenteval/_starter/certification/systems/northwind-cs-staging.yaml +16 -0
- agenteval/_starter/certification/systems/northwind-cs.yaml +17 -0
- agenteval/_starter/examples/agents/demo-good.yaml +41 -0
- agenteval/_starter/examples/agents/demo-weak.yaml +34 -0
- agenteval/_starter/examples/agents/openai-compatible.example.yaml +15 -0
- agenteval/_starter/examples/agents/webhook.example.yaml +21 -0
- agenteval/_starter/examples/attestations-certification.yaml +16 -0
- agenteval/_starter/examples/attestations.yaml +19 -0
- agenteval/_starter/examples/snapshots/sample-cluster.json +323 -0
- agenteval/_starter/exams/customer-service/cs-ecommerce-v1.yaml +222 -0
- agenteval/_starter/rules/industry-pci-dss/i18n/zh.yaml +27 -0
- agenteval/_starter/rules/industry-pci-dss/pack.yaml +9 -0
- agenteval/_starter/rules/industry-pci-dss/rules.yaml +83 -0
- agenteval/_starter/rules/us-ca/i18n/zh.yaml +21 -0
- agenteval/_starter/rules/us-ca/pack.yaml +9 -0
- agenteval/_starter/rules/us-ca/rules.yaml +72 -0
- agenteval/_starter/rules/us-federal/governance.yaml +216 -0
- agenteval/_starter/rules/us-federal/i18n/zh.yaml +107 -0
- agenteval/_starter/rules/us-federal/pack.yaml +8 -0
- agenteval/_starter/rules/us-federal/security.yaml +164 -0
- agenteval/certification/__init__.py +16 -0
- agenteval/certification/assess.py +137 -0
- agenteval/certification/issuer.py +165 -0
- agenteval/certification/models.py +113 -0
- agenteval/certification/render.py +100 -0
- agenteval/certification/signing.py +86 -0
- agenteval/cli.py +533 -0
- agenteval/collectors/__init__.py +30 -0
- agenteval/collectors/kubernetes.py +70 -0
- agenteval/collectors/normalize.py +247 -0
- agenteval/config.py +49 -0
- agenteval/engine/__init__.py +12 -0
- agenteval/engine/conditions.py +109 -0
- agenteval/engine/evaluator.py +133 -0
- agenteval/engine/loader.py +131 -0
- agenteval/evaluation/__init__.py +17 -0
- agenteval/evaluation/adapters.py +276 -0
- agenteval/evaluation/assertions.py +73 -0
- agenteval/evaluation/judges.py +214 -0
- agenteval/evaluation/messages.py +56 -0
- agenteval/evaluation/models.py +174 -0
- agenteval/evaluation/papers.py +103 -0
- agenteval/evaluation/report.py +80 -0
- agenteval/evaluation/runner.py +154 -0
- agenteval/i18n.py +118 -0
- agenteval/models.py +136 -0
- agenteval/reports/__init__.py +86 -0
- agenteval/reports/templates/certificate.html.j2 +178 -0
- agenteval/reports/templates/eval_report.html.j2 +191 -0
- agenteval/reports/templates/report.html.j2 +75 -0
- agenteval/schema.py +66 -0
- agenteval/schemas/attestations.schema.json +76 -0
- agenteval/schemas/exam.schema.json +85 -0
- agenteval/schemas/pack.schema.json +48 -0
- agenteval/schemas/policy.schema.json +67 -0
- agenteval/schemas/rule.schema.json +298 -0
- agenteval/schemas/system.schema.json +76 -0
- agenteval/schemas/translations.schema.json +32 -0
- agenteval_toolkit-0.1.0.dist-info/METADATA +307 -0
- agenteval_toolkit-0.1.0.dist-info/RECORD +67 -0
- agenteval_toolkit-0.1.0.dist-info/WHEEL +5 -0
- agenteval_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- agenteval_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- agenteval_toolkit-0.1.0.dist-info/licenses/NOTICE +19 -0
- 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]
|