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
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
+ }