agent-config-score 0.18.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.
- agent_config_score/__init__.py +1 -0
- agent_config_score/cli.py +457 -0
- agent_config_score/config.py +201 -0
- agent_config_score/doctor.py +183 -0
- agent_config_score/entrypoint.py +154 -0
- agent_config_score/gitdiff.py +110 -0
- agent_config_score/history.py +80 -0
- agent_config_score/initializer.py +103 -0
- agent_config_score/regression.py +113 -0
- agent_config_score/rules.py +83 -0
- agent_config_score/sarif.py +84 -0
- agent_config_score/scanner.py +496 -0
- agent_config_score-0.18.0.dist-info/METADATA +532 -0
- agent_config_score-0.18.0.dist-info/RECORD +18 -0
- agent_config_score-0.18.0.dist-info/WHEEL +5 -0
- agent_config_score-0.18.0.dist-info/entry_points.txt +3 -0
- agent_config_score-0.18.0.dist-info/licenses/LICENSE +21 -0
- agent_config_score-0.18.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.18.0"
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .config import ConfigError, Policy, load_policy
|
|
10
|
+
from .gitdiff import GitError, baseline_worktree, repository_root
|
|
11
|
+
from .history import load_history, summarize_history
|
|
12
|
+
from .initializer import InitError, initialize_repository
|
|
13
|
+
from .regression import compare, markdown_report
|
|
14
|
+
from .rules import RULES, get_rule
|
|
15
|
+
from .sarif import sarif_report
|
|
16
|
+
from .scanner import analyze, badge_svg, html_report
|
|
17
|
+
|
|
18
|
+
RESET = "\033[0m"
|
|
19
|
+
BOLD = "\033[1m"
|
|
20
|
+
COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m"}
|
|
21
|
+
GRADE_COLORS = {"A": "\033[32m", "B": "\033[32m", "C": "\033[33m", "D": "\033[33m", "F": "\033[31m"}
|
|
22
|
+
|
|
23
|
+
TOP_LEVEL_HELP = """usage:
|
|
24
|
+
agent-config-score [PATH] [scan options]
|
|
25
|
+
agent-config-score init [PATH] [options]
|
|
26
|
+
agent-config-score rules [RULE_ID] [options]
|
|
27
|
+
agent-config-score history [PATH] [options]
|
|
28
|
+
agent-config-score diff BASE_REF [options]
|
|
29
|
+
agent-config-score compare BASE HEAD [options]
|
|
30
|
+
|
|
31
|
+
Score and regression-check AI coding-agent instruction files.
|
|
32
|
+
|
|
33
|
+
commands:
|
|
34
|
+
init Add a repository policy and GitHub Actions workflow safely.
|
|
35
|
+
rules List or explain the stable AgentConfigScore rule catalog.
|
|
36
|
+
history Show locally recorded score snapshots and overall trend.
|
|
37
|
+
diff Compare a Git ref with the current working tree.
|
|
38
|
+
compare Compare two already checked-out repository trees.
|
|
39
|
+
|
|
40
|
+
common examples:
|
|
41
|
+
agent-config-score init
|
|
42
|
+
agent-config-score rules
|
|
43
|
+
agent-config-score rules curl-pipe-shell
|
|
44
|
+
agent-config-score .
|
|
45
|
+
agent-config-score history
|
|
46
|
+
agent-config-score diff origin/main
|
|
47
|
+
agent-config-score compare ../repo-base .
|
|
48
|
+
|
|
49
|
+
Run `agent-config-score <command> --help` for command-specific options.
|
|
50
|
+
Use `agent-config-score --version` to print the version.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _supports_color() -> bool:
|
|
55
|
+
return sys.stdout.isatty()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _c(text: str, color: str) -> str:
|
|
59
|
+
return f"{color}{text}{RESET}" if _supports_color() else text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_suppressed(report) -> None:
|
|
63
|
+
if not report.suppressed_findings:
|
|
64
|
+
return
|
|
65
|
+
print(f"\nSuppressed findings: {len(report.suppressed_findings)}")
|
|
66
|
+
for item in report.suppressed_findings:
|
|
67
|
+
finding = item.finding
|
|
68
|
+
suppression = item.suppression
|
|
69
|
+
location = finding.file + (f":{finding.line}" if finding.line else "")
|
|
70
|
+
print(f"~ SUPPRESSED {finding.code:20} {finding.message}")
|
|
71
|
+
print(f" {location}")
|
|
72
|
+
print(f" reason: {suppression.reason}")
|
|
73
|
+
print(f" expires: {suppression.expires.isoformat()}")
|
|
74
|
+
if suppression.paths:
|
|
75
|
+
print(f" paths: {', '.join(suppression.paths)}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def print_report(report) -> None:
|
|
79
|
+
grade = _c(report.grade, GRADE_COLORS[report.grade])
|
|
80
|
+
print(f"\n{BOLD if _supports_color() else ''}AgentConfigScore{RESET if _supports_color() else ''} {grade} {report.score}/100")
|
|
81
|
+
print(
|
|
82
|
+
f"Files: {len(report.files)} Estimated tokens: {report.estimated_tokens:,} "
|
|
83
|
+
f"Duplication: {report.duplicate_ratio:.0%} Suppressed: {len(report.suppressed_findings)}\n"
|
|
84
|
+
)
|
|
85
|
+
if not report.findings:
|
|
86
|
+
print(_c("OK No active findings", "\033[32m"))
|
|
87
|
+
else:
|
|
88
|
+
for f in report.findings:
|
|
89
|
+
icon = {"error": "X", "warning": "!", "info": "i"}.get(f.severity, "-")
|
|
90
|
+
loc = f.file + (f":{f.line}" if f.line else "")
|
|
91
|
+
prefix = _c(f"{icon} {f.severity.upper():7}", COLORS.get(f.severity, ""))
|
|
92
|
+
print(f"{prefix} {f.code:20} {f.message}")
|
|
93
|
+
print(f" {loc}")
|
|
94
|
+
_print_suppressed(report)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def print_regression(report) -> None:
|
|
98
|
+
sign = "+" if report.delta > 0 else ""
|
|
99
|
+
print(
|
|
100
|
+
f"\n{BOLD if _supports_color() else ''}AgentConfigScore regression{RESET if _supports_color() else ''} "
|
|
101
|
+
f"{report.base.grade} {report.base.score} -> {report.head.grade} {report.head.score} ({sign}{report.delta})"
|
|
102
|
+
)
|
|
103
|
+
print(
|
|
104
|
+
f"New findings: {len(report.new_findings)} Resolved: {len(report.resolved_findings)} "
|
|
105
|
+
f"Suppressed: {len(report.head.suppressed_findings)}\n"
|
|
106
|
+
)
|
|
107
|
+
for finding in report.new_findings:
|
|
108
|
+
location = finding.file + (f":{finding.line}" if finding.line else "")
|
|
109
|
+
prefix = _c(f"+ {finding.severity.upper():7}", COLORS.get(finding.severity, ""))
|
|
110
|
+
print(f"{prefix} {finding.code:20} {finding.message}")
|
|
111
|
+
print(f" {location}")
|
|
112
|
+
for finding in report.resolved_findings:
|
|
113
|
+
location = finding.file + (f":{finding.line}" if finding.line else "")
|
|
114
|
+
print(f"- RESOLVED {finding.code:20} {finding.message}")
|
|
115
|
+
print(f" {location}")
|
|
116
|
+
if not report.new_findings and not report.resolved_findings:
|
|
117
|
+
print("OK No active agent-config changes detected")
|
|
118
|
+
_print_suppressed(report.head)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_scan_parser() -> argparse.ArgumentParser:
|
|
122
|
+
p = argparse.ArgumentParser(prog="agent-config-score", description="Score and audit coding-agent instruction files.")
|
|
123
|
+
p.add_argument("path", nargs="?", default=".", help="Repository path (default: current directory)")
|
|
124
|
+
p.add_argument("--json", action="store_true", help="Print JSON instead of text")
|
|
125
|
+
p.add_argument("--html", metavar="FILE", help="Write a self-contained HTML report")
|
|
126
|
+
p.add_argument("--badge", metavar="FILE", help="Write an SVG score badge")
|
|
127
|
+
p.add_argument("--sarif", metavar="FILE", help="Write a SARIF 2.1.0 report for GitHub code scanning")
|
|
128
|
+
p.add_argument("--fail-under", type=int, default=None, metavar="N", help="Override the repository policy score floor")
|
|
129
|
+
return p
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_init_parser() -> argparse.ArgumentParser:
|
|
133
|
+
p = argparse.ArgumentParser(
|
|
134
|
+
prog="agent-config-score init",
|
|
135
|
+
description="Initialize AgentConfigScore policy and CI files without overwriting existing files by default.",
|
|
136
|
+
)
|
|
137
|
+
p.add_argument("path", nargs="?", default=".", help="Repository directory (default: current directory)")
|
|
138
|
+
p.add_argument("--no-workflow", action="store_true", help="Create only .agentconfigscore.json")
|
|
139
|
+
p.add_argument("--force", action="store_true", help="Overwrite conflicting generated files")
|
|
140
|
+
p.add_argument("--dry-run", action="store_true", help="Show the initialization plan without writing files")
|
|
141
|
+
return p
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_rules_parser() -> argparse.ArgumentParser:
|
|
145
|
+
p = argparse.ArgumentParser(
|
|
146
|
+
prog="agent-config-score rules",
|
|
147
|
+
description="List all stable rule IDs or explain one rule in detail.",
|
|
148
|
+
)
|
|
149
|
+
p.add_argument("rule_id", nargs="?", help="Optional rule ID, for example: curl-pipe-shell")
|
|
150
|
+
p.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
151
|
+
return p
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_history_parser() -> argparse.ArgumentParser:
|
|
155
|
+
p = argparse.ArgumentParser(
|
|
156
|
+
prog="agent-config-score history",
|
|
157
|
+
description="Show locally recorded AgentConfigScore snapshots and their overall trend.",
|
|
158
|
+
)
|
|
159
|
+
p.add_argument("path", nargs="?", default=".", help="Repository path (default: current directory)")
|
|
160
|
+
p.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
161
|
+
p.add_argument("--limit", type=int, default=20, metavar="N", help="Show at most the newest N snapshots (default: 20)")
|
|
162
|
+
return p
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _add_regression_options(p: argparse.ArgumentParser) -> None:
|
|
166
|
+
p.add_argument("--json", action="store_true", help="Print JSON instead of text")
|
|
167
|
+
p.add_argument("--markdown", metavar="FILE", help="Write a Markdown regression summary")
|
|
168
|
+
p.add_argument("--max-drop", type=int, default=None, metavar="N", help="Override the repository policy score-drop budget")
|
|
169
|
+
p.add_argument(
|
|
170
|
+
"--fail-on-new-errors",
|
|
171
|
+
action=argparse.BooleanOptionalAction,
|
|
172
|
+
default=None,
|
|
173
|
+
help="Override whether newly introduced error findings fail the check",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_compare_parser() -> argparse.ArgumentParser:
|
|
178
|
+
p = argparse.ArgumentParser(
|
|
179
|
+
prog="agent-config-score compare",
|
|
180
|
+
description="Compare two repository trees and fail only when agent configuration regresses.",
|
|
181
|
+
)
|
|
182
|
+
p.add_argument("base", help="Baseline repository directory")
|
|
183
|
+
p.add_argument("head", help="Candidate repository directory")
|
|
184
|
+
_add_regression_options(p)
|
|
185
|
+
return p
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_diff_parser() -> argparse.ArgumentParser:
|
|
189
|
+
p = argparse.ArgumentParser(
|
|
190
|
+
prog="agent-config-score diff",
|
|
191
|
+
description="Compare a Git ref with the current working tree without manually creating a baseline checkout.",
|
|
192
|
+
)
|
|
193
|
+
p.add_argument("base_ref", help="Baseline Git branch, tag, or commit (for example: origin/main)")
|
|
194
|
+
p.add_argument("--path", default=".", metavar="DIR", help="Path inside the Git repository (default: current directory)")
|
|
195
|
+
_add_regression_options(p)
|
|
196
|
+
return p
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _existing_dir(value: str) -> Path | None:
|
|
200
|
+
path = Path(value)
|
|
201
|
+
return path if path.exists() and path.is_dir() else None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resolve_regression_policy(args, policy: Policy) -> None:
|
|
205
|
+
args.max_drop = policy.max_drop if args.max_drop is None else args.max_drop
|
|
206
|
+
args.fail_on_new_errors = policy.fail_on_new_errors if args.fail_on_new_errors is None else args.fail_on_new_errors
|
|
207
|
+
if args.max_drop < 0:
|
|
208
|
+
raise ConfigError("--max-drop must be >= 0")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _finish_regression(report, args) -> int:
|
|
212
|
+
if args.markdown:
|
|
213
|
+
out = Path(args.markdown)
|
|
214
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
out.write_text(markdown_report(report), encoding="utf-8")
|
|
216
|
+
|
|
217
|
+
if args.json:
|
|
218
|
+
print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
|
|
219
|
+
else:
|
|
220
|
+
print_regression(report)
|
|
221
|
+
if args.markdown:
|
|
222
|
+
print(f"\nMarkdown report: {Path(args.markdown).resolve()}")
|
|
223
|
+
|
|
224
|
+
if report.delta < -args.max_drop:
|
|
225
|
+
return 1
|
|
226
|
+
if args.fail_on_new_errors and report.new_errors:
|
|
227
|
+
return 1
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _main_init(argv: list[str]) -> int:
|
|
232
|
+
args = build_init_parser().parse_args(argv)
|
|
233
|
+
try:
|
|
234
|
+
changes = initialize_repository(
|
|
235
|
+
Path(args.path),
|
|
236
|
+
include_workflow=not args.no_workflow,
|
|
237
|
+
force=args.force,
|
|
238
|
+
dry_run=args.dry_run,
|
|
239
|
+
)
|
|
240
|
+
except InitError as exc:
|
|
241
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
242
|
+
return 2
|
|
243
|
+
|
|
244
|
+
for change in changes:
|
|
245
|
+
if change.action == "unchanged":
|
|
246
|
+
label = "unchanged"
|
|
247
|
+
elif args.dry_run:
|
|
248
|
+
label = f"would {change.action}"
|
|
249
|
+
else:
|
|
250
|
+
label = f"{change.action}d" if change.action == "create" else "overwritten"
|
|
251
|
+
print(f"{label:12} {change.path.as_posix()}")
|
|
252
|
+
|
|
253
|
+
if args.dry_run:
|
|
254
|
+
print("Dry run: no files written.")
|
|
255
|
+
else:
|
|
256
|
+
print("\nAgentConfigScore initialized. Review and commit the generated files.")
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _main_rules(argv: list[str]) -> int:
|
|
261
|
+
args = build_rules_parser().parse_args(argv)
|
|
262
|
+
if args.rule_id:
|
|
263
|
+
rule = get_rule(args.rule_id)
|
|
264
|
+
if rule is None:
|
|
265
|
+
print(f"error: unknown rule ID: {args.rule_id}", file=sys.stderr)
|
|
266
|
+
return 2
|
|
267
|
+
if args.json:
|
|
268
|
+
print(json.dumps(rule.to_dict(), indent=2, ensure_ascii=False))
|
|
269
|
+
else:
|
|
270
|
+
print(rule.code)
|
|
271
|
+
print(f"Severity: {rule.severity}")
|
|
272
|
+
print(f"Category: {rule.category}")
|
|
273
|
+
print(f"Penalty: {rule.penalty}")
|
|
274
|
+
print(f"Summary: {rule.summary}")
|
|
275
|
+
print(f"\n{rule.description}")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
if args.json:
|
|
279
|
+
print(json.dumps([rule.to_dict() for rule in RULES], indent=2, ensure_ascii=False))
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
print(f"{'RULE ID':22} {'SEVERITY':8} {'CATEGORY':8} {'PENALTY':7} SUMMARY")
|
|
283
|
+
for rule in RULES:
|
|
284
|
+
print(f"{rule.code:22} {rule.severity:8} {rule.category:8} {rule.penalty:7} {rule.summary}")
|
|
285
|
+
print(f"\n{len(RULES)} rules. Run `agent-config-score rules RULE_ID` for details.")
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _format_timestamp(value: object) -> str:
|
|
290
|
+
if not isinstance(value, str) or not value:
|
|
291
|
+
return "-"
|
|
292
|
+
return value[:19].replace("T", " ")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _main_history(argv: list[str]) -> int:
|
|
296
|
+
args = build_history_parser().parse_args(argv)
|
|
297
|
+
root = Path(args.path)
|
|
298
|
+
if not root.exists() or not root.is_dir():
|
|
299
|
+
print(f"error: not a directory: {root}", file=sys.stderr)
|
|
300
|
+
return 2
|
|
301
|
+
if args.limit < 1:
|
|
302
|
+
print("error: --limit must be >= 1", file=sys.stderr)
|
|
303
|
+
return 2
|
|
304
|
+
|
|
305
|
+
history = load_history(root)
|
|
306
|
+
visible = history[-args.limit:]
|
|
307
|
+
summary = summarize_history(history)
|
|
308
|
+
|
|
309
|
+
if args.json:
|
|
310
|
+
print(json.dumps({"summary": summary, "history": visible}, indent=2, ensure_ascii=False))
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
print("\nAgentConfigScore history")
|
|
314
|
+
if not visible:
|
|
315
|
+
print("No local score history found.")
|
|
316
|
+
print("Expected: .agentconfigscore/history/index.json")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
print(f"\n{'DATE':19} {'SCORE':>5} {'GRADE':>5} {'CHANGE':>7} COMMIT")
|
|
320
|
+
previous_score: int | float | None = None
|
|
321
|
+
for item in visible:
|
|
322
|
+
score = item.get("score")
|
|
323
|
+
score_text = str(score) if isinstance(score, (int, float)) else "-"
|
|
324
|
+
grade = str(item.get("grade", "-"))
|
|
325
|
+
change = "-"
|
|
326
|
+
if isinstance(score, (int, float)) and previous_score is not None:
|
|
327
|
+
delta = score - previous_score
|
|
328
|
+
change = f"{delta:+g}"
|
|
329
|
+
if isinstance(score, (int, float)):
|
|
330
|
+
previous_score = score
|
|
331
|
+
commit = item.get("commit")
|
|
332
|
+
commit_text = str(commit)[:12] if commit else "-"
|
|
333
|
+
print(f"{_format_timestamp(item.get('timestamp')):19} {score_text:>5} {grade:>5} {change:>7} {commit_text}")
|
|
334
|
+
|
|
335
|
+
delta = summary["delta"]
|
|
336
|
+
if delta is None:
|
|
337
|
+
trend_text = "unknown"
|
|
338
|
+
else:
|
|
339
|
+
arrow = {"up": "↑", "down": "↓", "flat": "→"}[summary["trend"]]
|
|
340
|
+
trend_text = f"{arrow} {delta:+g} overall"
|
|
341
|
+
print(f"\nTrend: {trend_text} across {summary['scored_count']} scored snapshots")
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _main_compare(argv: list[str]) -> int:
|
|
346
|
+
args = build_compare_parser().parse_args(argv)
|
|
347
|
+
base = _existing_dir(args.base)
|
|
348
|
+
head = _existing_dir(args.head)
|
|
349
|
+
if base is None:
|
|
350
|
+
print(f"error: not a directory: {args.base}", file=sys.stderr)
|
|
351
|
+
return 2
|
|
352
|
+
if head is None:
|
|
353
|
+
print(f"error: not a directory: {args.head}", file=sys.stderr)
|
|
354
|
+
return 2
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
policy = load_policy(base)
|
|
358
|
+
load_policy(head)
|
|
359
|
+
_resolve_regression_policy(args, policy)
|
|
360
|
+
except ConfigError as exc:
|
|
361
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
362
|
+
return 2
|
|
363
|
+
|
|
364
|
+
return _finish_regression(compare(base, head, suppressions=policy.suppressions), args)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _main_diff(argv: list[str]) -> int:
|
|
368
|
+
args = build_diff_parser().parse_args(argv)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
repo = repository_root(Path(args.path))
|
|
372
|
+
with baseline_worktree(repo, args.base_ref) as (base, sha):
|
|
373
|
+
policy = load_policy(base)
|
|
374
|
+
load_policy(repo)
|
|
375
|
+
_resolve_regression_policy(args, policy)
|
|
376
|
+
report = compare(base, repo, suppressions=policy.suppressions)
|
|
377
|
+
report.base.root = f"git:{args.base_ref}@{sha[:12]}"
|
|
378
|
+
report.head.root = str(repo)
|
|
379
|
+
except (GitError, ConfigError) as exc:
|
|
380
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
381
|
+
return 2
|
|
382
|
+
|
|
383
|
+
return _finish_regression(report, args)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _main_scan(argv: list[str]) -> int:
|
|
387
|
+
args = build_scan_parser().parse_args(argv)
|
|
388
|
+
root = Path(args.path)
|
|
389
|
+
if not root.exists() or not root.is_dir():
|
|
390
|
+
print(f"error: not a directory: {root}", file=sys.stderr)
|
|
391
|
+
return 2
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
policy = load_policy(root)
|
|
395
|
+
except ConfigError as exc:
|
|
396
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
397
|
+
return 2
|
|
398
|
+
|
|
399
|
+
fail_under = policy.fail_under if args.fail_under is None else args.fail_under
|
|
400
|
+
if fail_under is not None and not 0 <= fail_under <= 100:
|
|
401
|
+
print("error: --fail-under must be between 0 and 100", file=sys.stderr)
|
|
402
|
+
return 2
|
|
403
|
+
|
|
404
|
+
report = analyze(root, suppressions=policy.suppressions)
|
|
405
|
+
|
|
406
|
+
if args.html:
|
|
407
|
+
out = Path(args.html)
|
|
408
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
out.write_text(html_report(report), encoding="utf-8")
|
|
410
|
+
if args.badge:
|
|
411
|
+
out = Path(args.badge)
|
|
412
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
out.write_text(badge_svg(report), encoding="utf-8")
|
|
414
|
+
if args.sarif:
|
|
415
|
+
out = Path(args.sarif)
|
|
416
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
out.write_text(json.dumps(sarif_report(report), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
418
|
+
|
|
419
|
+
if args.json:
|
|
420
|
+
print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
|
|
421
|
+
else:
|
|
422
|
+
print_report(report)
|
|
423
|
+
if args.html:
|
|
424
|
+
print(f"\nHTML report: {Path(args.html).resolve()}")
|
|
425
|
+
if args.badge:
|
|
426
|
+
print(f"Badge: {Path(args.badge).resolve()}")
|
|
427
|
+
if args.sarif:
|
|
428
|
+
print(f"SARIF report: {Path(args.sarif).resolve()}")
|
|
429
|
+
|
|
430
|
+
if fail_under is not None and report.score < fail_under:
|
|
431
|
+
return 1
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def main(argv: list[str] | None = None) -> int:
|
|
436
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
437
|
+
if args == ["--version"] or args == ["-V"]:
|
|
438
|
+
print(f"AgentConfigScore {__version__}")
|
|
439
|
+
return 0
|
|
440
|
+
if args == ["--help"] or args == ["-h"]:
|
|
441
|
+
print(TOP_LEVEL_HELP, end="")
|
|
442
|
+
return 0
|
|
443
|
+
if args and args[0] == "init":
|
|
444
|
+
return _main_init(args[1:])
|
|
445
|
+
if args and args[0] == "rules":
|
|
446
|
+
return _main_rules(args[1:])
|
|
447
|
+
if args and args[0] == "history":
|
|
448
|
+
return _main_history(args[1:])
|
|
449
|
+
if args and args[0] == "compare":
|
|
450
|
+
return _main_compare(args[1:])
|
|
451
|
+
if args and args[0] == "diff":
|
|
452
|
+
return _main_diff(args[1:])
|
|
453
|
+
return _main_scan(args)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime, timezone
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .rules import get_rule
|
|
10
|
+
|
|
11
|
+
CONFIG_NAME = ".agentconfigscore.json"
|
|
12
|
+
SCHEMA_URL = "https://raw.githubusercontent.com/LE0-Lin/AgentConfigScore/v0/schema/agentconfigscore.schema.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(ValueError):
|
|
16
|
+
"""Raised when AgentConfigScore repository configuration is invalid."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Suppression:
|
|
21
|
+
rule: str
|
|
22
|
+
reason: str
|
|
23
|
+
expires: date
|
|
24
|
+
paths: tuple[str, ...] = ()
|
|
25
|
+
|
|
26
|
+
def applies_to(self, rule: str, file: str) -> bool:
|
|
27
|
+
if self.rule != rule:
|
|
28
|
+
return False
|
|
29
|
+
if not self.paths:
|
|
30
|
+
return True
|
|
31
|
+
if file == "(repo)":
|
|
32
|
+
return False
|
|
33
|
+
return any(fnmatch(file, pattern) for pattern in self.paths)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
data = {
|
|
37
|
+
"rule": self.rule,
|
|
38
|
+
"reason": self.reason,
|
|
39
|
+
"expires": self.expires.isoformat(),
|
|
40
|
+
}
|
|
41
|
+
if self.paths:
|
|
42
|
+
data["paths"] = list(self.paths)
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Policy:
|
|
48
|
+
max_drop: int = 0
|
|
49
|
+
fail_on_new_errors: bool = False
|
|
50
|
+
fail_under: int | None = None
|
|
51
|
+
suppressions: tuple[Suppression, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_ALLOWED_TOP_LEVEL = {"$schema", "version", "policy", "suppressions"}
|
|
55
|
+
_ALLOWED_POLICY_KEYS = {"max_drop", "fail_on_new_errors", "fail_under"}
|
|
56
|
+
_ALLOWED_SUPPRESSION_KEYS = {"rule", "reason", "expires", "paths"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _utc_today() -> date:
|
|
60
|
+
return datetime.now(timezone.utc).date()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require_int(name: str, value, *, minimum: int, maximum: int | None = None) -> int:
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
65
|
+
raise ConfigError(f"{name} must be an integer")
|
|
66
|
+
if value < minimum or (maximum is not None and value > maximum):
|
|
67
|
+
if maximum is None:
|
|
68
|
+
raise ConfigError(f"{name} must be >= {minimum}")
|
|
69
|
+
raise ConfigError(f"{name} must be between {minimum} and {maximum}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_suppression(raw: object, index: int, *, today: date) -> Suppression:
|
|
74
|
+
name = f"suppressions[{index}]"
|
|
75
|
+
if not isinstance(raw, dict):
|
|
76
|
+
raise ConfigError(f"{name} must be a JSON object")
|
|
77
|
+
|
|
78
|
+
unknown = sorted(set(raw) - _ALLOWED_SUPPRESSION_KEYS)
|
|
79
|
+
if unknown:
|
|
80
|
+
raise ConfigError(f"unknown {name} key(s): {', '.join(unknown)}")
|
|
81
|
+
|
|
82
|
+
rule = raw.get("rule")
|
|
83
|
+
if not isinstance(rule, str) or not rule.strip():
|
|
84
|
+
raise ConfigError(f"{name}.rule must be a non-empty string")
|
|
85
|
+
rule = rule.strip()
|
|
86
|
+
if get_rule(rule) is None:
|
|
87
|
+
raise ConfigError(f"{name}.rule references unknown rule ID: {rule}")
|
|
88
|
+
|
|
89
|
+
reason = raw.get("reason")
|
|
90
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
91
|
+
raise ConfigError(f"{name}.reason must be a non-empty string")
|
|
92
|
+
reason = reason.strip()
|
|
93
|
+
if len(reason) > 500:
|
|
94
|
+
raise ConfigError(f"{name}.reason must be at most 500 characters")
|
|
95
|
+
|
|
96
|
+
expires_raw = raw.get("expires")
|
|
97
|
+
if not isinstance(expires_raw, str):
|
|
98
|
+
raise ConfigError(f"{name}.expires must be an ISO date (YYYY-MM-DD)")
|
|
99
|
+
try:
|
|
100
|
+
expires = date.fromisoformat(expires_raw)
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise ConfigError(f"{name}.expires must be an ISO date (YYYY-MM-DD)") from exc
|
|
103
|
+
if expires.isoformat() != expires_raw:
|
|
104
|
+
raise ConfigError(f"{name}.expires must use YYYY-MM-DD format")
|
|
105
|
+
if expires < today:
|
|
106
|
+
raise ConfigError(
|
|
107
|
+
f"{name} expired on {expires.isoformat()}; remove it or renew it with a reviewed reason"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
raw_paths = raw.get("paths")
|
|
111
|
+
paths: tuple[str, ...] = ()
|
|
112
|
+
if raw_paths is not None:
|
|
113
|
+
if not isinstance(raw_paths, list) or not raw_paths:
|
|
114
|
+
raise ConfigError(f"{name}.paths must be a non-empty JSON array when provided")
|
|
115
|
+
normalized: list[str] = []
|
|
116
|
+
for path_index, value in enumerate(raw_paths):
|
|
117
|
+
if not isinstance(value, str) or not value.strip():
|
|
118
|
+
raise ConfigError(f"{name}.paths[{path_index}] must be a non-empty string")
|
|
119
|
+
normalized.append(value.strip())
|
|
120
|
+
if len(set(normalized)) != len(normalized):
|
|
121
|
+
raise ConfigError(f"{name}.paths must not contain duplicates")
|
|
122
|
+
paths = tuple(normalized)
|
|
123
|
+
|
|
124
|
+
return Suppression(rule=rule, reason=reason, expires=expires, paths=paths)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_policy(data: object, *, today: date | None = None) -> Policy:
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
raise ConfigError("configuration root must be a JSON object")
|
|
130
|
+
|
|
131
|
+
unknown = sorted(set(data) - _ALLOWED_TOP_LEVEL)
|
|
132
|
+
if unknown:
|
|
133
|
+
raise ConfigError(f"unknown configuration key(s): {', '.join(unknown)}")
|
|
134
|
+
|
|
135
|
+
schema_uri = data.get("$schema")
|
|
136
|
+
if schema_uri is not None and (not isinstance(schema_uri, str) or not schema_uri.strip()):
|
|
137
|
+
raise ConfigError("$schema must be a non-empty string when provided")
|
|
138
|
+
|
|
139
|
+
version = data.get("version", 1)
|
|
140
|
+
if version != 1:
|
|
141
|
+
raise ConfigError("version must be 1")
|
|
142
|
+
|
|
143
|
+
raw_policy = data.get("policy", {})
|
|
144
|
+
if not isinstance(raw_policy, dict):
|
|
145
|
+
raise ConfigError("policy must be a JSON object")
|
|
146
|
+
|
|
147
|
+
unknown_policy = sorted(set(raw_policy) - _ALLOWED_POLICY_KEYS)
|
|
148
|
+
if unknown_policy:
|
|
149
|
+
raise ConfigError(f"unknown policy key(s): {', '.join(unknown_policy)}")
|
|
150
|
+
|
|
151
|
+
max_drop = _require_int("policy.max_drop", raw_policy.get("max_drop", 0), minimum=0)
|
|
152
|
+
|
|
153
|
+
fail_on_new_errors = raw_policy.get("fail_on_new_errors", False)
|
|
154
|
+
if not isinstance(fail_on_new_errors, bool):
|
|
155
|
+
raise ConfigError("policy.fail_on_new_errors must be true or false")
|
|
156
|
+
|
|
157
|
+
fail_under = raw_policy.get("fail_under")
|
|
158
|
+
if fail_under is not None:
|
|
159
|
+
fail_under = _require_int("policy.fail_under", fail_under, minimum=0, maximum=100)
|
|
160
|
+
|
|
161
|
+
raw_suppressions = data.get("suppressions", [])
|
|
162
|
+
if not isinstance(raw_suppressions, list):
|
|
163
|
+
raise ConfigError("suppressions must be a JSON array")
|
|
164
|
+
evaluation_date = _utc_today() if today is None else today
|
|
165
|
+
suppressions = tuple(
|
|
166
|
+
_parse_suppression(raw, index, today=evaluation_date)
|
|
167
|
+
for index, raw in enumerate(raw_suppressions)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
identities = [(item.rule, item.paths) for item in suppressions]
|
|
171
|
+
if len(set(identities)) != len(identities):
|
|
172
|
+
raise ConfigError("duplicate suppressions for the same rule and path scope are not allowed")
|
|
173
|
+
|
|
174
|
+
return Policy(
|
|
175
|
+
max_drop=max_drop,
|
|
176
|
+
fail_on_new_errors=fail_on_new_errors,
|
|
177
|
+
fail_under=fail_under,
|
|
178
|
+
suppressions=suppressions,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_policy(root: Path) -> Policy:
|
|
183
|
+
path = root.resolve() / CONFIG_NAME
|
|
184
|
+
if not path.exists():
|
|
185
|
+
return Policy()
|
|
186
|
+
if not path.is_file():
|
|
187
|
+
raise ConfigError(f"{CONFIG_NAME} exists but is not a file")
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
raise ConfigError(f"could not read {CONFIG_NAME}: {exc}") from exc
|
|
193
|
+
except json.JSONDecodeError as exc:
|
|
194
|
+
raise ConfigError(
|
|
195
|
+
f"invalid JSON in {CONFIG_NAME} at line {exc.lineno}, column {exc.colno}: {exc.msg}"
|
|
196
|
+
) from exc
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
return parse_policy(data)
|
|
200
|
+
except ConfigError as exc:
|
|
201
|
+
raise ConfigError(f"invalid {CONFIG_NAME}: {exc}") from exc
|