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
agenteval/cli.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 The AgentEval Authors
|
|
3
|
+
"""AgentEval command-line interface.
|
|
4
|
+
|
|
5
|
+
Exit codes are stable and meant for CI gates:
|
|
6
|
+
0 success / threshold not exceeded / eligible / valid
|
|
7
|
+
1 policy result is negative (failed checks, evaluation not passed, not eligible, invalid certificate)
|
|
8
|
+
2 usage or input error (bad rules, bad paper, unreadable file, missing option)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from agenteval import __version__
|
|
23
|
+
from agenteval.collectors import collect_from_kubernetes, collect_from_snapshot_file
|
|
24
|
+
from agenteval.config import default_rules_dir, load_attestations
|
|
25
|
+
from agenteval.engine import RuleLoadError, load_rules, run_scan
|
|
26
|
+
from agenteval.models import Severity, Status
|
|
27
|
+
from agenteval.reports import render
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
help="AgentEval: scan AI infrastructure against rule packs, evaluate AI agents with standard exams, "
|
|
31
|
+
"and issue signed certificates when both pass.",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
)
|
|
34
|
+
rules_app = typer.Typer(help="Validate and list rule packs.", no_args_is_help=True)
|
|
35
|
+
app.add_typer(rules_app, name="rules")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SourceKind(str, Enum):
|
|
39
|
+
snapshot = "snapshot"
|
|
40
|
+
kubernetes = "kubernetes"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutFormat(str, Enum):
|
|
44
|
+
json = "json"
|
|
45
|
+
sarif = "sarif"
|
|
46
|
+
html = "html"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Lang(str, Enum):
|
|
50
|
+
en = "en"
|
|
51
|
+
zh = "zh"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FailOn(str, Enum):
|
|
55
|
+
critical = "critical"
|
|
56
|
+
high = "high"
|
|
57
|
+
medium = "medium"
|
|
58
|
+
low = "low"
|
|
59
|
+
none = "none"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
CLI_MESSAGES = {
|
|
63
|
+
"scan_summary": {
|
|
64
|
+
"en": "Scan {id}: {assets} assets · {passed} passed · {failed} failed · {manual} awaiting attestation · pass rate {score}%",
|
|
65
|
+
"zh": "扫描 {id}: 资产 {assets} · 通过 {passed} · 不通过 {failed} · 待证明 {manual} · 通过率 {score}%"},
|
|
66
|
+
"blocking": {"en": "{n} failed checks at severity {sev} or above",
|
|
67
|
+
"zh": "存在 {n} 个达到 {sev} 及以上的不通过项"},
|
|
68
|
+
"rules_ok": {"en": "✓ {n} rules validated", "zh": "✓ {n} 条规则校验通过"},
|
|
69
|
+
"rules_unverified": {"en": "Note: {n} regulation mappings are pending legal/compliance review (verified=false)",
|
|
70
|
+
"zh": "注意:{n} 处法规映射尚未经法务/合规审核(verified=false)"},
|
|
71
|
+
"papers_ok": {"en": "✓ {papers} exam papers and {cases} cases validated",
|
|
72
|
+
"zh": "✓ {papers} 份试卷、{cases} 个样本校验通过"},
|
|
73
|
+
"paper_line": {"en": "{id:<22} v{version:<7} {cases:>3} cases {title}\n{blank:<22} dimensions {dims}",
|
|
74
|
+
"zh": "{id:<22} v{version:<7} {cases:>3} 题 {title}\n{blank:<22} 维度 {dims}"},
|
|
75
|
+
"eval_summary": {
|
|
76
|
+
"en": "Eval {id}: {agent} × {paper} → {score} (grade {grade}) · cases passed {cp}/{c} · critical failures {cf} · judge {judge}",
|
|
77
|
+
"zh": "评测 {id}: {agent} × {paper} → {score} 分({grade}) · 样本通过 {cp}/{c} · 关键项失败 {cf} · 裁判 {judge}"},
|
|
78
|
+
"attest_written": {"en": "Pre-deployment testing attestation written: {path}", "zh": "已生成部署前测试证明: {path}"},
|
|
79
|
+
"attest_skipped": {"en": "Evaluation did not pass; no pre-deployment testing attestation written",
|
|
80
|
+
"zh": "评测未通过,未生成部署前测试证明"},
|
|
81
|
+
"snapshot_written": {"en": "Exported {n} workloads to {path}", "zh": "已导出 {n} 个工作负载到 {path}"},
|
|
82
|
+
"snapshot_required": {"en": "--snapshot is required when --source=snapshot",
|
|
83
|
+
"zh": "source=snapshot 时必须提供 --snapshot"},
|
|
84
|
+
"collect_failed": {"en": "Collection failed: {e}", "zh": "采集失败: {e}"},
|
|
85
|
+
"unknown_system": {"en": "Unknown AI system: {ref} (registered: {ids})", "zh": "未登记的 AI 系统: {ref}(可用: {ids})"},
|
|
86
|
+
"bad_inputs": {"en": "Cannot read scan/eval JSON (produce them with -f json): {e}",
|
|
87
|
+
"zh": "读取扫描/评测 JSON 失败(请使用 -f json 输出): {e}"},
|
|
88
|
+
"eligible_demo": {"en": "Eligible — DEMO certificate only", "zh": "满足颁证条件 —— 仅可颁发 DEMO 证书"},
|
|
89
|
+
"eligible": {"en": "Eligible for certification", "zh": "满足颁证条件"},
|
|
90
|
+
"not_eligible": {"en": "Not eligible", "zh": "不满足颁证条件"},
|
|
91
|
+
"issued": {"en": "Certificate issued: {id} → {dir}", "zh": "已签发证书: {id} → {dir}"},
|
|
92
|
+
"sig_ok": {"en": "Signature valid", "zh": "签名有效"},
|
|
93
|
+
"sig_bad": {"en": "Signature INVALID", "zh": "签名无效"},
|
|
94
|
+
"state": {"en": "State: {state}", "zh": "状态: {state}"},
|
|
95
|
+
"offline": {"en": " (offline; revocation not checked)", "zh": "(离线,未检查吊销)"},
|
|
96
|
+
"demo_cert": {"en": "DEMO certificate", "zh": "演示证书"},
|
|
97
|
+
"need_pubkey": {"en": "No public key given and no local signing key at {path}; pass --public-key",
|
|
98
|
+
"zh": "未指定 --public-key,且本地不存在签名密钥 {path}"},
|
|
99
|
+
"schemas_written": {"en": "Wrote {n} JSON Schemas to {dir}", "zh": "已导出 {n} 个 JSON Schema 到 {dir}"},
|
|
100
|
+
"init_done": {"en": "Starter content in {dir}: {copied} files copied, {skipped} existing files kept",
|
|
101
|
+
"zh": "入门内容已写入 {dir}:复制 {copied} 个文件,保留已有文件 {skipped} 个"},
|
|
102
|
+
"init_missing": {"en": "Bundled starter content not found in this installation",
|
|
103
|
+
"zh": "当前安装中找不到内置的入门内容"},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _m(key: str, lang: str, **kw) -> str:
|
|
108
|
+
return CLI_MESSAGES[key].get(lang, CLI_MESSAGES[key]["en"]).format(**kw)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _default_lang() -> "Lang":
|
|
112
|
+
"""English by default; set AGENTEVAL_LANG=zh for Chinese output."""
|
|
113
|
+
return Lang.zh if os.environ.get("AGENTEVAL_LANG", "").lower().startswith("zh") else Lang.en
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
LANG_OPT = typer.Option(_default_lang, "--lang", "-l", help="Output language for reports and console: en / zh ($AGENTEVAL_LANG).")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _err(msg: str) -> None:
|
|
120
|
+
typer.secho(msg, fg=typer.colors.RED, err=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_rules_or_exit(path: Path):
|
|
124
|
+
try:
|
|
125
|
+
return load_rules(path)
|
|
126
|
+
except RuleLoadError as e:
|
|
127
|
+
_err(str(e))
|
|
128
|
+
raise typer.Exit(2)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def version() -> None:
|
|
133
|
+
"""Print the version."""
|
|
134
|
+
typer.echo(__version__)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def scan(
|
|
139
|
+
source: SourceKind = typer.Option(SourceKind.snapshot, help="Where to collect assets from."),
|
|
140
|
+
snapshot: Optional[Path] = typer.Option(None, help="Offline snapshot JSON (required for --source snapshot)."),
|
|
141
|
+
context: Optional[str] = typer.Option(None, help="kubeconfig context."),
|
|
142
|
+
namespace: list[str] = typer.Option([], "--namespace", "-n", help="Limit to a namespace; repeatable."),
|
|
143
|
+
in_cluster: bool = typer.Option(False, help="Use the in-cluster ServiceAccount."),
|
|
144
|
+
rules: Path = typer.Option(None, help="Rules directory or file (default ./rules or $AGENTEVAL_RULES_DIR)."),
|
|
145
|
+
attestations: list[Path] = typer.Option([], help="Attestation YAML; repeatable, files are merged."),
|
|
146
|
+
fmt: OutFormat = typer.Option(OutFormat.json, "--format", "-f", help="Report format."),
|
|
147
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Report path (default stdout)."),
|
|
148
|
+
lang: Lang = LANG_OPT,
|
|
149
|
+
fail_on: FailOn = typer.Option(FailOn.high, help="Exit 1 if any failed check is at this severity or above."),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Scan a cluster (or snapshot) and evaluate it against the rule packs."""
|
|
152
|
+
rule_set = _load_rules_or_exit(rules or default_rules_dir())
|
|
153
|
+
try:
|
|
154
|
+
atts = load_attestations(list(attestations))
|
|
155
|
+
if source == SourceKind.snapshot:
|
|
156
|
+
if not snapshot:
|
|
157
|
+
_err(_m("snapshot_required", lang.value))
|
|
158
|
+
raise typer.Exit(2)
|
|
159
|
+
assets, _ = collect_from_snapshot_file(snapshot)
|
|
160
|
+
source_label = f"snapshot:{snapshot.name}"
|
|
161
|
+
else:
|
|
162
|
+
assets, _ = collect_from_kubernetes(context=context, namespaces=namespace or None, in_cluster=in_cluster)
|
|
163
|
+
source_label = f"kubernetes:{context or ('in-cluster' if in_cluster else 'current-context')}"
|
|
164
|
+
except typer.Exit:
|
|
165
|
+
raise
|
|
166
|
+
except Exception as e:
|
|
167
|
+
_err(_m("collect_failed", lang.value, e=e))
|
|
168
|
+
raise typer.Exit(2)
|
|
169
|
+
|
|
170
|
+
result = run_scan(rule_set, assets, source=source_label, attestations=atts)
|
|
171
|
+
text = render(result, fmt.value, lang.value)
|
|
172
|
+
if output:
|
|
173
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
output.write_text(text, encoding="utf-8")
|
|
175
|
+
else:
|
|
176
|
+
sys.stdout.write(text + "\n")
|
|
177
|
+
|
|
178
|
+
s = result.summary
|
|
179
|
+
typer.secho(_m("scan_summary", lang.value, id=result.scan_id, assets=result.asset_count,
|
|
180
|
+
passed=s.by_status.get("pass", 0), failed=s.by_status.get("fail", 0),
|
|
181
|
+
manual=s.by_status.get("manual", 0), score=s.score), err=True)
|
|
182
|
+
if fail_on != FailOn.none:
|
|
183
|
+
threshold = Severity(fail_on.value).rank
|
|
184
|
+
blocking = [f for f in result.findings if f.status == Status.failed and f.severity.rank >= threshold]
|
|
185
|
+
if blocking:
|
|
186
|
+
_err(_m("blocking", lang.value, n=len(blocking), sev=fail_on.value))
|
|
187
|
+
raise typer.Exit(1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command("snapshot")
|
|
191
|
+
def snapshot_cmd(
|
|
192
|
+
context: Optional[str] = typer.Option(None, help="kubeconfig context."),
|
|
193
|
+
namespace: list[str] = typer.Option([], "--namespace", "-n", help="Limit to a namespace; repeatable."),
|
|
194
|
+
in_cluster: bool = typer.Option(False, help="Use the in-cluster ServiceAccount."),
|
|
195
|
+
output: Path = typer.Option(..., "--output", "-o", help="Snapshot output path."),
|
|
196
|
+
lang: Lang = LANG_OPT,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Export a read-only configuration snapshot for offline or air-gapped scanning."""
|
|
199
|
+
from agenteval.collectors.kubernetes import fetch_snapshot
|
|
200
|
+
|
|
201
|
+
snap = fetch_snapshot(context=context, namespaces=namespace or None, in_cluster=in_cluster)
|
|
202
|
+
output.write_text(json.dumps(snap, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
|
203
|
+
typer.echo(_m("snapshot_written", lang.value, n=len(snap["workloads"]), path=output))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@rules_app.command("validate")
|
|
207
|
+
def rules_validate(rules: Path = typer.Option(None, help="Rules directory or file."), lang: Lang = LANG_OPT) -> None:
|
|
208
|
+
"""Validate rules, pack metadata and translations against the schemas."""
|
|
209
|
+
rule_set = _load_rules_or_exit(rules or default_rules_dir())
|
|
210
|
+
unverified = sum(1 for r in rule_set for s in r.sources if not s.verified)
|
|
211
|
+
typer.secho(_m("rules_ok", lang.value, n=len(rule_set)), fg=typer.colors.GREEN)
|
|
212
|
+
if unverified:
|
|
213
|
+
typer.secho(_m("rules_unverified", lang.value, n=unverified), fg=typer.colors.YELLOW)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@rules_app.command("list")
|
|
217
|
+
def rules_list(rules: Path = typer.Option(None, help="Rules directory or file."), lang: Lang = LANG_OPT) -> None:
|
|
218
|
+
"""List rules with their pack, severity and type."""
|
|
219
|
+
for r in _load_rules_or_exit(rules or default_rules_dir()):
|
|
220
|
+
title = (r.translations.get(lang.value) or {}).get("title") or r.title
|
|
221
|
+
typer.echo(f"{r.id:<14} {r.pack:<10} {r.severity.value:<9} {r.type.value:<12} {title}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command("init")
|
|
225
|
+
def init_cmd(directory: Path = typer.Argument(Path("."), help="Where to create rules/, exams/, certification/ and examples/."),
|
|
226
|
+
lang: Lang = LANG_OPT) -> None:
|
|
227
|
+
"""Copy the starter rule packs, exam papers, certification policy and examples into a directory to customize."""
|
|
228
|
+
import shutil
|
|
229
|
+
from importlib import resources
|
|
230
|
+
|
|
231
|
+
from agenteval.config import STARTER_DIRS, starter_root
|
|
232
|
+
|
|
233
|
+
src = starter_root()
|
|
234
|
+
if not all((src / d).is_dir() for d in STARTER_DIRS):
|
|
235
|
+
typer.echo(_m("init_missing", lang.value), err=True)
|
|
236
|
+
raise typer.Exit(2)
|
|
237
|
+
copied = skipped = 0
|
|
238
|
+
for d in STARTER_DIRS:
|
|
239
|
+
for f in sorted((src / d).rglob("*")):
|
|
240
|
+
if not f.is_file() or "__pycache__" in f.parts:
|
|
241
|
+
continue
|
|
242
|
+
dest = directory / f.relative_to(src)
|
|
243
|
+
if dest.exists():
|
|
244
|
+
skipped += 1
|
|
245
|
+
continue
|
|
246
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
text = f.read_text(encoding="utf-8") if f.suffix in (".yaml", ".yml") else None
|
|
248
|
+
if text and text.startswith("# yaml-language-server: $schema="):
|
|
249
|
+
# Re-point the editor modeline at the schemas exported to <dir>/.schemas/.
|
|
250
|
+
first, _, rest = text.partition("\n")
|
|
251
|
+
rel = os.path.relpath(directory / ".schemas" / Path(first.split("=", 1)[1]).name, dest.parent)
|
|
252
|
+
dest.write_text(f"# yaml-language-server: $schema={Path(rel).as_posix()}\n{rest}", encoding="utf-8")
|
|
253
|
+
else:
|
|
254
|
+
shutil.copy2(f, dest)
|
|
255
|
+
copied += 1
|
|
256
|
+
schemas_dir = directory / ".schemas"
|
|
257
|
+
schemas_dir.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
for f in resources.files("agenteval").joinpath("schemas").iterdir():
|
|
259
|
+
if f.name.endswith(".schema.json"):
|
|
260
|
+
(schemas_dir / f.name).write_text(f.read_text(encoding="utf-8"), encoding="utf-8")
|
|
261
|
+
typer.echo(_m("init_done", lang.value, dir=directory, copied=copied, skipped=skipped))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command("schemas")
|
|
265
|
+
def schemas_cmd(out: Path = typer.Option(Path("schemas"), "--out", "-o", help="Directory to write the JSON Schemas to."),
|
|
266
|
+
lang: Lang = LANG_OPT) -> None:
|
|
267
|
+
"""Export the JSON Schemas (rules, packs, translations, attestations, exams, systems) for editor validation."""
|
|
268
|
+
from importlib import resources
|
|
269
|
+
|
|
270
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
files = [f for f in resources.files("agenteval").joinpath("schemas").iterdir() if f.name.endswith(".schema.json")]
|
|
272
|
+
for f in files:
|
|
273
|
+
(out / f.name).write_text(f.read_text(encoding="utf-8"), encoding="utf-8")
|
|
274
|
+
typer.echo(_m("schemas_written", lang.value, n=len(files), dir=out))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ======================= Agent evaluation =======================
|
|
278
|
+
|
|
279
|
+
eval_app = typer.Typer(help="Evaluate AI agents with standard exam papers.", no_args_is_help=True)
|
|
280
|
+
app.add_typer(eval_app, name="eval")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class JudgeKind(str, Enum):
|
|
284
|
+
heuristic = "heuristic"
|
|
285
|
+
anthropic = "anthropic"
|
|
286
|
+
openai = "openai"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class EvalFormat(str, Enum):
|
|
290
|
+
html = "html"
|
|
291
|
+
json = "json"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@eval_app.command("papers")
|
|
295
|
+
def eval_papers(exams: Path = typer.Option(None, help="Exam library directory (default ./exams or $AGENTEVAL_EXAMS_DIR)."),
|
|
296
|
+
lang: Lang = LANG_OPT) -> None:
|
|
297
|
+
"""List exam papers and their dimension weights."""
|
|
298
|
+
from agenteval.evaluation import PaperError, load_papers
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
papers = load_papers(exams)
|
|
302
|
+
except PaperError as e:
|
|
303
|
+
_err(str(e))
|
|
304
|
+
raise typer.Exit(2)
|
|
305
|
+
for p in papers.values():
|
|
306
|
+
dims = ", ".join(f"{d.id}:{int(d.weight * 100)}%" for d in p.dimensions)
|
|
307
|
+
typer.echo(_m("paper_line", lang.value, id=p.id, version=p.version, cases=len(p.cases), title=p.title,
|
|
308
|
+
blank="", dims=dims))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@eval_app.command("validate")
|
|
312
|
+
def eval_validate(exams: Path = typer.Option(None, help="Exam library directory."), lang: Lang = LANG_OPT) -> None:
|
|
313
|
+
"""Validate exam papers (schema, dimensions, tools, assertions)."""
|
|
314
|
+
from agenteval.evaluation import PaperError, load_papers
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
papers = load_papers(exams)
|
|
318
|
+
except PaperError as e:
|
|
319
|
+
_err(str(e))
|
|
320
|
+
raise typer.Exit(2)
|
|
321
|
+
n = sum(len(p.cases) for p in papers.values())
|
|
322
|
+
typer.secho(_m("papers_ok", lang.value, papers=len(papers), cases=n), fg=typer.colors.GREEN)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@eval_app.command("run")
|
|
326
|
+
def eval_run(
|
|
327
|
+
paper: str = typer.Option(..., "--paper", "-p", help="Exam paper ID or path."),
|
|
328
|
+
agent: Path = typer.Option(..., "--agent", "-a", help="Agent-under-test config YAML."),
|
|
329
|
+
judge: JudgeKind = typer.Option(JudgeKind.heuristic, help="Rubric judge."),
|
|
330
|
+
judge_model: Optional[str] = typer.Option(None, help="LLM judge model (or $AGENTEVAL_JUDGE_MODEL)."),
|
|
331
|
+
judge_base_url: Optional[str] = typer.Option(None, help="Base URL for an OpenAI-compatible judge."),
|
|
332
|
+
judge_key_env: Optional[str] = typer.Option(None, help="Environment variable that holds the judge API key."),
|
|
333
|
+
case: list[str] = typer.Option([], "--case", help="Run only this case ID; repeatable."),
|
|
334
|
+
concurrency: int = typer.Option(1, min=1, max=16, help="Cases to run in parallel."),
|
|
335
|
+
exams: Path = typer.Option(None, help="Exam library directory."),
|
|
336
|
+
fmt: EvalFormat = typer.Option(EvalFormat.html, "--format", "-f", help="Report format."),
|
|
337
|
+
lang: Lang = LANG_OPT,
|
|
338
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Report path (default stdout)."),
|
|
339
|
+
attest_asset: Optional[str] = typer.Option(None, help="On pass, write FED-GOV-A02 pre-deployment testing evidence for this asset."),
|
|
340
|
+
attest_output: Path = typer.Option(Path("attestation-eval.yaml"), help="Where to write that attestation."),
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Run an exam paper against an agent and score it. Exit 0 pass / 1 not passed / 2 error."""
|
|
343
|
+
import yaml as _yaml
|
|
344
|
+
|
|
345
|
+
from agenteval.evaluation import (
|
|
346
|
+
Agent, AdapterError, JudgeError, PaperError, load_agent_config, make_judge, render as render_eval,
|
|
347
|
+
resolve_paper, run_exam, to_attestation,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
exam = resolve_paper(paper, exams)
|
|
352
|
+
bot = Agent(load_agent_config(agent))
|
|
353
|
+
j = make_judge(judge.value, judge_model, judge_base_url, judge_key_env)
|
|
354
|
+
except (PaperError, AdapterError, JudgeError, OSError, ValueError) as e:
|
|
355
|
+
_err(str(e))
|
|
356
|
+
raise typer.Exit(2)
|
|
357
|
+
|
|
358
|
+
run = run_exam(exam, bot, j, concurrency=concurrency, case_ids=case or None)
|
|
359
|
+
text = render_eval(run, fmt.value, lang.value)
|
|
360
|
+
if output:
|
|
361
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
output.write_text(text, encoding="utf-8")
|
|
363
|
+
else:
|
|
364
|
+
sys.stdout.write(text + "\n")
|
|
365
|
+
|
|
366
|
+
s = run.stats
|
|
367
|
+
typer.secho(_m("eval_summary", lang.value, id=run.run_id, agent=run.agent_name, paper=run.paper_id,
|
|
368
|
+
score=run.overall_score, grade=run.grade, cp=s.cases_passed, c=s.cases,
|
|
369
|
+
cf=s.critical_failures, judge=run.judge),
|
|
370
|
+
err=True, fg=typer.colors.GREEN if run.passed else typer.colors.RED)
|
|
371
|
+
if attest_asset:
|
|
372
|
+
if run.passed:
|
|
373
|
+
entry = to_attestation(run, attest_asset)
|
|
374
|
+
attest_output.write_text(_yaml.safe_dump({"attestations": [entry]}, allow_unicode=True, sort_keys=False),
|
|
375
|
+
encoding="utf-8")
|
|
376
|
+
typer.echo(_m("attest_written", lang.value, path=attest_output), err=True)
|
|
377
|
+
else:
|
|
378
|
+
_err(_m("attest_skipped", lang.value))
|
|
379
|
+
if not run.passed:
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ======================= Certification =======================
|
|
384
|
+
|
|
385
|
+
cert_app = typer.Typer(help="Certify an AI system when its scan and evaluation both pass the policy.",
|
|
386
|
+
no_args_is_help=True)
|
|
387
|
+
app.add_typer(cert_app, name="cert")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _system(ref: str, lang: str):
|
|
391
|
+
from agenteval.certification import load_system_file, load_systems
|
|
392
|
+
|
|
393
|
+
p = Path(ref)
|
|
394
|
+
if p.suffix in (".yaml", ".yml") and p.exists():
|
|
395
|
+
return load_system_file(p)
|
|
396
|
+
systems = load_systems()
|
|
397
|
+
if ref not in systems:
|
|
398
|
+
_err(_m("unknown_system", lang, ref=ref, ids=", ".join(sorted(systems))))
|
|
399
|
+
raise typer.Exit(2)
|
|
400
|
+
return systems[ref]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _decision(system_ref: str, scan: Path, eval_path: Path, rules: Optional[Path], lang: str):
|
|
404
|
+
from agenteval.certification import decide, load_policy
|
|
405
|
+
from agenteval.engine import load_packs
|
|
406
|
+
from agenteval.evaluation.models import EvalRun
|
|
407
|
+
from agenteval.models import ScanResult
|
|
408
|
+
|
|
409
|
+
system = _system(system_ref, lang)
|
|
410
|
+
try:
|
|
411
|
+
scan_r = ScanResult.model_validate_json(scan.read_text(encoding="utf-8"))
|
|
412
|
+
run = EvalRun.model_validate_json(eval_path.read_text(encoding="utf-8"))
|
|
413
|
+
except Exception as e:
|
|
414
|
+
_err(_m("bad_inputs", lang, e=e))
|
|
415
|
+
raise typer.Exit(2)
|
|
416
|
+
policy = load_policy()
|
|
417
|
+
return system, policy, decide(system, scan_r, run, load_packs(rules or default_rules_dir()), policy)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _print_decision(d, lang: str) -> None:
|
|
421
|
+
from agenteval.certification import gate_text
|
|
422
|
+
|
|
423
|
+
for g in d.gates:
|
|
424
|
+
mark = "✓" if g.ok else ("!" if g.kind == "demo" else "✕")
|
|
425
|
+
color = typer.colors.GREEN if g.ok else (typer.colors.YELLOW if g.kind == "demo" else typer.colors.RED)
|
|
426
|
+
typer.secho(f" {mark} {gate_text(g.code, g.params, lang)}", fg=color)
|
|
427
|
+
if d.eligible:
|
|
428
|
+
typer.secho(_m("eligible_demo" if d.demo else "eligible", lang),
|
|
429
|
+
fg=typer.colors.YELLOW if d.demo else typer.colors.GREEN)
|
|
430
|
+
else:
|
|
431
|
+
typer.secho(_m("not_eligible", lang), fg=typer.colors.RED)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@cert_app.command("check")
|
|
435
|
+
def cert_check(
|
|
436
|
+
system: str = typer.Option(..., "--system", "-s", help="Registered system ID or system YAML path."),
|
|
437
|
+
scan: Path = typer.Option(..., help="Scan result JSON (agenteval scan -f json)."),
|
|
438
|
+
eval_path: Path = typer.Option(..., "--eval", help="Evaluation result JSON (agenteval eval run -f json)."),
|
|
439
|
+
rules: Optional[Path] = typer.Option(None, help="Rules directory (for pack tiers)."),
|
|
440
|
+
lang: Lang = LANG_OPT,
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Check the certification gates. Exit 0 eligible / 1 not eligible / 2 input error."""
|
|
443
|
+
_, _, d = _decision(system, scan, eval_path, rules, lang.value)
|
|
444
|
+
_print_decision(d, lang.value)
|
|
445
|
+
if not d.eligible:
|
|
446
|
+
raise typer.Exit(1)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@cert_app.command("issue")
|
|
450
|
+
def cert_issue(
|
|
451
|
+
system: str = typer.Option(..., "--system", "-s", help="Registered system ID or system YAML path."),
|
|
452
|
+
scan: Path = typer.Option(..., help="Scan result JSON."),
|
|
453
|
+
eval_path: Path = typer.Option(..., "--eval", help="Evaluation result JSON."),
|
|
454
|
+
rules: Optional[Path] = typer.Option(None, help="Rules directory (for pack tiers)."),
|
|
455
|
+
out_dir: Path = typer.Option(Path("certificates"), "--out-dir", "-o", help="Output directory."),
|
|
456
|
+
pdf: bool = typer.Option(False, help="Also export PDFs (requires the 'pdf' extra and a Chromium browser)."),
|
|
457
|
+
verify_url: Optional[str] = typer.Option(
|
|
458
|
+
None, help="Public verification URL template with {id}, printed as a QR code ($AGENTEVAL_VERIFY_URL). "
|
|
459
|
+
"Without it the certificate shows offline verification instructions."),
|
|
460
|
+
lang: Lang = LANG_OPT,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Issue a signed certificate if every gate passes: JSON envelope plus English and Chinese HTML."""
|
|
463
|
+
from agenteval.certification import issue, load_or_create_key
|
|
464
|
+
from agenteval.certification.render import render_certificate, to_pdf
|
|
465
|
+
|
|
466
|
+
sysobj, policy, d = _decision(system, scan, eval_path, rules, lang.value)
|
|
467
|
+
_print_decision(d, lang.value)
|
|
468
|
+
if not d.eligible:
|
|
469
|
+
raise typer.Exit(1)
|
|
470
|
+
env = issue(sysobj, d, policy, load_or_create_key(), verify_url or os.environ.get("AGENTEVAL_VERIFY_URL"))
|
|
471
|
+
cid = env["certificate"]["id"]
|
|
472
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
(out_dir / f"{cid}.json").write_text(json.dumps(env, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
474
|
+
for lg in ("en", "zh"):
|
|
475
|
+
(out_dir / f"{cid}.{lg}.html").write_text(render_certificate(env, lg), encoding="utf-8")
|
|
476
|
+
if pdf:
|
|
477
|
+
for lg in ("en", "zh"):
|
|
478
|
+
to_pdf(render_certificate(env, lg, printable_button=False), out_dir / f"{cid}.{lg}.pdf")
|
|
479
|
+
typer.secho(_m("issued", lang.value, id=cid, dir=out_dir), fg=typer.colors.GREEN)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@cert_app.command("verify")
|
|
483
|
+
def cert_verify(
|
|
484
|
+
cert: Path = typer.Argument(..., help="Certificate JSON envelope."),
|
|
485
|
+
public_key: Optional[Path] = typer.Option(None, help="Issuer public key PEM (default: public half of the local signing key)."),
|
|
486
|
+
status_url: Optional[str] = typer.Option(
|
|
487
|
+
None, help="Revocation status URL template with {id}; must return JSON with a 'state' field ($AGENTEVAL_STATUS_URL)."),
|
|
488
|
+
lang: Lang = LANG_OPT,
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Verify the signature, expiry and (optionally) online revocation state. Exit 0 valid / 1 invalid."""
|
|
491
|
+
from agenteval.certification import certificate_state, default_key_path, load_or_create_key, load_public_key, verify
|
|
492
|
+
|
|
493
|
+
env = json.loads(cert.read_text(encoding="utf-8"))
|
|
494
|
+
if public_key:
|
|
495
|
+
pub = load_public_key(public_key.read_bytes())
|
|
496
|
+
elif default_key_path().exists():
|
|
497
|
+
pub = load_or_create_key().public_key()
|
|
498
|
+
else:
|
|
499
|
+
_err(_m("need_pubkey", lang.value, path=default_key_path()))
|
|
500
|
+
raise typer.Exit(2)
|
|
501
|
+
sig_ok = verify(env, pub)
|
|
502
|
+
status_url = status_url or os.environ.get("AGENTEVAL_STATUS_URL")
|
|
503
|
+
stored = None
|
|
504
|
+
if status_url:
|
|
505
|
+
import httpx
|
|
506
|
+
r = httpx.get(status_url.format(id=env["certificate"]["id"]), timeout=15)
|
|
507
|
+
stored = r.json().get("state") if r.status_code == 200 else "not_found"
|
|
508
|
+
state = stored if stored in ("revoked", "superseded", "not_found") else certificate_state(env)
|
|
509
|
+
typer.secho(_m("sig_ok" if sig_ok else "sig_bad", lang.value), fg=typer.colors.GREEN if sig_ok else typer.colors.RED)
|
|
510
|
+
typer.echo(_m("state", lang.value, state=state) + ("" if status_url else _m("offline", lang.value)))
|
|
511
|
+
if env["certificate"].get("demo"):
|
|
512
|
+
typer.secho(_m("demo_cert", lang.value), fg=typer.colors.YELLOW)
|
|
513
|
+
if not (sig_ok and state == "active"):
|
|
514
|
+
raise typer.Exit(1)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@cert_app.command("keygen")
|
|
518
|
+
def cert_keygen(
|
|
519
|
+
out: Path = typer.Option(None, help="Private key path (default $AGENTEVAL_SIGNING_KEY or ./.agenteval/signing-key.pem)."),
|
|
520
|
+
public_out: Optional[Path] = typer.Option(None, "--public-out", help="Also write the public key PEM here, to publish for verifiers."),
|
|
521
|
+
) -> None:
|
|
522
|
+
"""Create (or load) the Ed25519 signing key and print its public key and fingerprint."""
|
|
523
|
+
from agenteval.certification import key_id, load_or_create_key, public_pem
|
|
524
|
+
|
|
525
|
+
key = load_or_create_key(out)
|
|
526
|
+
if public_out:
|
|
527
|
+
public_out.parent.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
public_out.write_text(public_pem(key), encoding="utf-8")
|
|
529
|
+
typer.echo(public_pem(key) + f"key_id: {key_id(key)}")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
if __name__ == "__main__": # pragma: no cover
|
|
533
|
+
app()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 The AgentEval Authors
|
|
3
|
+
"""Collectors: fetch raw configuration snapshots from the target environment and normalize them into assets."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from agenteval.models import Asset
|
|
11
|
+
|
|
12
|
+
from .normalize import normalize_snapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def collect_from_snapshot_file(path: str | Path) -> tuple[list[Asset], dict]:
|
|
16
|
+
"""Offline mode: read JSON exported by `agenteval snapshot` (or a hand-built test snapshot)."""
|
|
17
|
+
with Path(path).open(encoding="utf-8") as fh:
|
|
18
|
+
snapshot = json.load(fh)
|
|
19
|
+
return normalize_snapshot(snapshot), snapshot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def collect_from_kubernetes(context: str | None = None, namespaces: list[str] | None = None,
|
|
23
|
+
in_cluster: bool = False) -> tuple[list[Asset], dict]:
|
|
24
|
+
from .kubernetes import fetch_snapshot # Lazy import: offline mode still works without the kubernetes dependency
|
|
25
|
+
|
|
26
|
+
snapshot = fetch_snapshot(context=context, namespaces=namespaces, in_cluster=in_cluster)
|
|
27
|
+
return normalize_snapshot(snapshot), snapshot
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["normalize_snapshot", "collect_from_snapshot_file", "collect_from_kubernetes"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 The AgentEval Authors
|
|
3
|
+
"""Read-only Kubernetes collector.
|
|
4
|
+
|
|
5
|
+
Calls only list/get APIs; see deploy/k8s/scanner-rbac.yaml for the required permissions.
|
|
6
|
+
Note: the collector never reads Secret contents; plaintext-secret detection relies only on variable names with env.value in Pod templates.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_client(context: str | None, in_cluster: bool):
|
|
15
|
+
try:
|
|
16
|
+
from kubernetes import client, config
|
|
17
|
+
except ImportError as e: # pragma: no cover
|
|
18
|
+
raise RuntimeError("The kubernetes dependency is not installed; run: pip install 'agenteval[kubernetes]'") from e
|
|
19
|
+
if in_cluster:
|
|
20
|
+
config.load_incluster_config()
|
|
21
|
+
else:
|
|
22
|
+
config.load_kube_config(context=context)
|
|
23
|
+
return client
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _items(api_client, resp) -> list[dict[str, Any]]:
|
|
27
|
+
return [api_client.sanitize_for_serialization(i) for i in resp.items]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch_snapshot(context: str | None = None, namespaces: list[str] | None = None,
|
|
31
|
+
in_cluster: bool = False) -> dict[str, Any]:
|
|
32
|
+
client = _load_client(context, in_cluster)
|
|
33
|
+
api_client = client.ApiClient()
|
|
34
|
+
apps = client.AppsV1Api(api_client)
|
|
35
|
+
core = client.CoreV1Api(api_client)
|
|
36
|
+
net = client.NetworkingV1Api(api_client)
|
|
37
|
+
|
|
38
|
+
def gather(all_ns_fn, ns_fn, kind: str | None = None):
|
|
39
|
+
if namespaces:
|
|
40
|
+
out = []
|
|
41
|
+
for ns in namespaces:
|
|
42
|
+
out.extend(_items(api_client, ns_fn(ns)))
|
|
43
|
+
else:
|
|
44
|
+
out = _items(api_client, all_ns_fn())
|
|
45
|
+
if kind:
|
|
46
|
+
for o in out:
|
|
47
|
+
o.setdefault("kind", kind)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
workloads = (
|
|
51
|
+
gather(apps.list_deployment_for_all_namespaces, apps.list_namespaced_deployment, "Deployment")
|
|
52
|
+
+ gather(apps.list_stateful_set_for_all_namespaces, apps.list_namespaced_stateful_set, "StatefulSet")
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
cluster_name = context
|
|
56
|
+
if not cluster_name:
|
|
57
|
+
try:
|
|
58
|
+
from kubernetes import config
|
|
59
|
+
_, active = config.list_kube_config_contexts()
|
|
60
|
+
cluster_name = (active or {}).get("context", {}).get("cluster")
|
|
61
|
+
except Exception:
|
|
62
|
+
cluster_name = None
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"cluster": {"name": cluster_name or "in-cluster"},
|
|
66
|
+
"workloads": workloads,
|
|
67
|
+
"services": gather(core.list_service_for_all_namespaces, core.list_namespaced_service),
|
|
68
|
+
"ingresses": gather(net.list_ingress_for_all_namespaces, net.list_namespaced_ingress),
|
|
69
|
+
"network_policies": gather(net.list_network_policy_for_all_namespaces, net.list_namespaced_network_policy),
|
|
70
|
+
}
|