agentcheckup 0.4.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.
- agentcheckup/__init__.py +27 -0
- agentcheckup/__main__.py +6 -0
- agentcheckup/cli.py +242 -0
- agentcheckup/conflicts.py +253 -0
- agentcheckup/discovery.py +206 -0
- agentcheckup/engine.py +146 -0
- agentcheckup/loader.py +214 -0
- agentcheckup/models.py +166 -0
- agentcheckup/report.py +274 -0
- agentcheckup/rules/__init__.py +12 -0
- agentcheckup/rules/base.py +91 -0
- agentcheckup/rules/config_rules.py +180 -0
- agentcheckup/rules/docs.py +124 -0
- agentcheckup/rules/injection.py +127 -0
- agentcheckup/rules/naming.py +100 -0
- agentcheckup/rules/safety.py +131 -0
- agentcheckup/rules/schema.py +143 -0
- agentcheckup/tokens.py +69 -0
- agentcheckup-0.4.0.dist-info/METADATA +261 -0
- agentcheckup-0.4.0.dist-info/RECORD +23 -0
- agentcheckup-0.4.0.dist-info/WHEEL +4 -0
- agentcheckup-0.4.0.dist-info/entry_points.txt +2 -0
- agentcheckup-0.4.0.dist-info/licenses/LICENSE +21 -0
agentcheckup/models.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Core data model for agentcheckup.
|
|
2
|
+
|
|
3
|
+
Everything here is deliberately dependency-free so the package keeps working
|
|
4
|
+
across MCP spec revisions without chasing an SDK release.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Severity(str, Enum):
|
|
15
|
+
ERROR = "error"
|
|
16
|
+
WARNING = "warning"
|
|
17
|
+
INFO = "info"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def rank(self) -> int:
|
|
21
|
+
return {"error": 3, "warning": 2, "info": 1}[self.value]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Finding:
|
|
26
|
+
"""A single lint result.
|
|
27
|
+
|
|
28
|
+
``rule`` is a stable id (AGT0xx) so CI configs and suppressions never break
|
|
29
|
+
when wording changes.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
rule: str
|
|
33
|
+
severity: Severity
|
|
34
|
+
message: str
|
|
35
|
+
tool: str | None = None
|
|
36
|
+
hint: str | None = None
|
|
37
|
+
where: str | None = None
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
out: dict[str, Any] = {
|
|
41
|
+
"rule": self.rule,
|
|
42
|
+
"severity": self.severity.value,
|
|
43
|
+
"message": self.message,
|
|
44
|
+
}
|
|
45
|
+
if self.tool:
|
|
46
|
+
out["tool"] = self.tool
|
|
47
|
+
if self.hint:
|
|
48
|
+
out["hint"] = self.hint
|
|
49
|
+
if self.where:
|
|
50
|
+
out["where"] = self.where
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Tool:
|
|
56
|
+
"""One entry from ``tools/list``."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
description: str = ""
|
|
60
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
annotations: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_raw(cls, data: dict[str, Any]) -> Tool:
|
|
66
|
+
schema = data.get("inputSchema") or data.get("input_schema") or {}
|
|
67
|
+
if not isinstance(schema, dict):
|
|
68
|
+
schema = {}
|
|
69
|
+
annotations = data.get("annotations") or {}
|
|
70
|
+
if not isinstance(annotations, dict):
|
|
71
|
+
annotations = {}
|
|
72
|
+
description = data.get("description") or ""
|
|
73
|
+
if not isinstance(description, str):
|
|
74
|
+
description = str(description)
|
|
75
|
+
return cls(
|
|
76
|
+
name=str(data.get("name", "")),
|
|
77
|
+
description=description,
|
|
78
|
+
input_schema=schema,
|
|
79
|
+
annotations=annotations,
|
|
80
|
+
raw=data,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def properties(self) -> dict[str, Any]:
|
|
84
|
+
props = self.input_schema.get("properties")
|
|
85
|
+
return props if isinstance(props, dict) else {}
|
|
86
|
+
|
|
87
|
+
def required(self) -> list[str]:
|
|
88
|
+
req = self.input_schema.get("required")
|
|
89
|
+
if not isinstance(req, list):
|
|
90
|
+
return []
|
|
91
|
+
return [str(x) for x in req]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ServerConfig:
|
|
96
|
+
"""A server entry lifted from an MCP client config file."""
|
|
97
|
+
|
|
98
|
+
name: str
|
|
99
|
+
command: str | None = None
|
|
100
|
+
args: list[str] = field(default_factory=list)
|
|
101
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
102
|
+
url: str | None = None
|
|
103
|
+
source: str | None = None
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_raw(cls, name: str, data: dict[str, Any], source: str | None = None) -> ServerConfig:
|
|
107
|
+
args = data.get("args") or []
|
|
108
|
+
if not isinstance(args, list):
|
|
109
|
+
args = [str(args)]
|
|
110
|
+
env = data.get("env") or {}
|
|
111
|
+
if not isinstance(env, dict):
|
|
112
|
+
env = {}
|
|
113
|
+
return cls(
|
|
114
|
+
name=name,
|
|
115
|
+
command=data.get("command"),
|
|
116
|
+
args=[str(a) for a in args],
|
|
117
|
+
env={str(k): str(v) for k, v in env.items()},
|
|
118
|
+
url=data.get("url") or data.get("serverUrl"),
|
|
119
|
+
source=source,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def grade_for(score: int) -> str:
|
|
124
|
+
if score >= 90:
|
|
125
|
+
return "A"
|
|
126
|
+
if score >= 80:
|
|
127
|
+
return "B"
|
|
128
|
+
if score >= 70:
|
|
129
|
+
return "C"
|
|
130
|
+
if score >= 60:
|
|
131
|
+
return "D"
|
|
132
|
+
return "F"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: Points charged against a manifest's budget.
|
|
136
|
+
#:
|
|
137
|
+
#: ``info`` is capped rather than free: a rule that fires on 18 of 18 tools in
|
|
138
|
+
#: the official MCP servers is expressing a preference, but a report full of
|
|
139
|
+
#: nudges should not still earn an A either.
|
|
140
|
+
SEVERITY_WEIGHT = {Severity.ERROR: 10, Severity.WARNING: 3, Severity.INFO: 1}
|
|
141
|
+
|
|
142
|
+
#: Info findings can cost at most this many points in total.
|
|
143
|
+
INFO_CAP = 10
|
|
144
|
+
|
|
145
|
+
#: Points of budget granted per tool.
|
|
146
|
+
BUDGET_PER_TOOL = 20
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def score_findings(findings: list[Finding], tool_count: int = 1) -> int:
|
|
150
|
+
"""Score 0-100, normalised per tool so a big manifest is not punished for size.
|
|
151
|
+
|
|
152
|
+
Calibrated against four real cases rather than invented: the three official
|
|
153
|
+
MCP servers (18 tools, 12 warnings, 77 nudges) land at 87; a manifest with a
|
|
154
|
+
hard collision, an unpinned package and a plaintext token lands at 79; the
|
|
155
|
+
deliberately broken fixture lands at 35; a clean manifest at 100.
|
|
156
|
+
|
|
157
|
+
A flat subtraction scored the official servers 0/100, which tells the reader
|
|
158
|
+
nothing and makes the tool look broken.
|
|
159
|
+
"""
|
|
160
|
+
penalty = sum(SEVERITY_WEIGHT[f.severity] for f in findings)
|
|
161
|
+
infos = sum(1 for f in findings if f.severity is Severity.INFO)
|
|
162
|
+
if infos:
|
|
163
|
+
# Replace the per-finding info charge with the capped one.
|
|
164
|
+
penalty += min(infos, INFO_CAP) - infos
|
|
165
|
+
budget = BUDGET_PER_TOOL * max(tool_count, 1)
|
|
166
|
+
return round(100 * max(0.0, 1 - penalty / budget))
|
agentcheckup/report.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Render a Report as text, JSON, or SARIF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import unicodedata
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import rules as _rules
|
|
10
|
+
from .engine import Report
|
|
11
|
+
from .models import Finding, Severity
|
|
12
|
+
|
|
13
|
+
__version__ = "0.4.0"
|
|
14
|
+
|
|
15
|
+
SARIF_LEVEL = {
|
|
16
|
+
Severity.ERROR: "error",
|
|
17
|
+
Severity.WARNING: "warning",
|
|
18
|
+
Severity.INFO: "note",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
INVISIBLE = frozenset("\u200b\u200c\u200d\u2060\ufeff")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def display(text: str) -> str:
|
|
25
|
+
"""Make invisible characters visible in terminal output.
|
|
26
|
+
|
|
27
|
+
A finding about hidden text is useless if the report itself renders it
|
|
28
|
+
invisibly - the reviewer would see an empty-looking name and move on.
|
|
29
|
+
"""
|
|
30
|
+
out: list[str] = []
|
|
31
|
+
for ch in text:
|
|
32
|
+
if ch in INVISIBLE or unicodedata.category(ch) == "Cf":
|
|
33
|
+
out.append(f"\\u{ord(ch):04x}")
|
|
34
|
+
else:
|
|
35
|
+
out.append(ch)
|
|
36
|
+
return "".join(out)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rule_metadata() -> list[dict[str, Any]]:
|
|
40
|
+
out: list[dict[str, Any]] = []
|
|
41
|
+
for rule in [*_rules.TOOL_RULES, *_rules.SUITE_RULES, *_rules.CONFIG_RULES]:
|
|
42
|
+
out.append(
|
|
43
|
+
{
|
|
44
|
+
"id": rule.id,
|
|
45
|
+
"name": rule.id,
|
|
46
|
+
"shortDescription": {"text": rule.title},
|
|
47
|
+
"defaultConfiguration": {"level": SARIF_LEVEL[rule.severity]},
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
return sorted(out, key=lambda r: r["id"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _finding_lines(findings: list[Finding], verbose: bool) -> list[str]:
|
|
54
|
+
out: list[str] = []
|
|
55
|
+
for finding in findings:
|
|
56
|
+
target = f" [{display(finding.tool)}]" if finding.tool else ""
|
|
57
|
+
out.append(f" {finding.rule} {finding.message}{target}")
|
|
58
|
+
if finding.hint:
|
|
59
|
+
out.append(f" fix: {finding.hint}")
|
|
60
|
+
if verbose and finding.where:
|
|
61
|
+
out.append(f" at: {finding.where}")
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rule_title(rule_id: str) -> str:
|
|
66
|
+
for rule in [*_rules.TOOL_RULES, *_rules.SUITE_RULES, *_rules.CONFIG_RULES]:
|
|
67
|
+
if rule.id == rule_id:
|
|
68
|
+
return rule.title
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _nudge_lines(findings: list[Finding]) -> list[str]:
|
|
73
|
+
"""Collapse info findings into one line per rule.
|
|
74
|
+
|
|
75
|
+
Measured on the three official MCP servers: 68 info findings across 18 tools,
|
|
76
|
+
most from rules that fire on nearly every tool. Printed individually they
|
|
77
|
+
bury the two findings that matter, and a report nobody reads is a report
|
|
78
|
+
nobody acts on.
|
|
79
|
+
"""
|
|
80
|
+
by_rule: dict[str, list[Finding]] = {}
|
|
81
|
+
for finding in findings:
|
|
82
|
+
by_rule.setdefault(finding.rule, []).append(finding)
|
|
83
|
+
|
|
84
|
+
out: list[str] = []
|
|
85
|
+
for rule, group in sorted(by_rule.items(), key=lambda kv: (-len(kv[1]), kv[0])):
|
|
86
|
+
title = rule_title(rule) or group[0].message
|
|
87
|
+
out.append(f" {rule} {title} ({len(group)})")
|
|
88
|
+
names = [display(f.tool) for f in group if f.tool]
|
|
89
|
+
if names:
|
|
90
|
+
preview = ", ".join(names[:3])
|
|
91
|
+
if len(names) > 3:
|
|
92
|
+
preview += f", +{len(names) - 3} more"
|
|
93
|
+
out.append(f" {preview}")
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: A rule that fires on this many tools is one root cause, not N problems.
|
|
98
|
+
AGGREGATE_AT = 5
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _serious_lines(findings: list[Finding], verbose: bool) -> list[str]:
|
|
102
|
+
"""Individual findings, except for rules that repeat: those collapse.
|
|
103
|
+
|
|
104
|
+
The official git server produced 12 identical "parameter has no description"
|
|
105
|
+
warnings, one per tool. Twelve near-identical lines hide everything else,
|
|
106
|
+
while one line naming all twelve tools is both shorter and more actionable.
|
|
107
|
+
"""
|
|
108
|
+
by_rule: dict[str, list[Finding]] = {}
|
|
109
|
+
for finding in findings:
|
|
110
|
+
by_rule.setdefault(finding.rule, []).append(finding)
|
|
111
|
+
|
|
112
|
+
ordered = sorted(
|
|
113
|
+
by_rule.items(),
|
|
114
|
+
key=lambda kv: (-kv[1][0].severity.rank, -len(kv[1]), kv[0]),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
out: list[str] = []
|
|
118
|
+
for rule, group in ordered:
|
|
119
|
+
if len(group) < AGGREGATE_AT:
|
|
120
|
+
out.extend(_finding_lines(group, verbose))
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
title = rule_title(rule) or group[0].message
|
|
124
|
+
out.append(f" {rule} {title} ({len(group)} tools)")
|
|
125
|
+
names = [display(f.tool) for f in group if f.tool]
|
|
126
|
+
if names:
|
|
127
|
+
preview = ", ".join(names[:4])
|
|
128
|
+
if len(names) > 4:
|
|
129
|
+
preview += f", +{len(names) - 4} more"
|
|
130
|
+
out.append(f" {preview}")
|
|
131
|
+
hint = next((f.hint for f in group if f.hint), "")
|
|
132
|
+
if hint:
|
|
133
|
+
out.append(f" fix: {hint}")
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_text(report: Report, verbose: bool = False) -> str:
|
|
138
|
+
lines: list[str] = []
|
|
139
|
+
if report.kind == "audit":
|
|
140
|
+
lines.append(
|
|
141
|
+
f"agentcheckup {__version__} | {report.server_count} server(s) | "
|
|
142
|
+
f"{report.tool_count} tool(s) | ~{report.tokens:,} tokens per request"
|
|
143
|
+
)
|
|
144
|
+
elif report.kind == "config":
|
|
145
|
+
lines.append(f"agentcheckup {__version__} | {report.tool_count} server(s)")
|
|
146
|
+
else:
|
|
147
|
+
lines.append(
|
|
148
|
+
f"agentcheckup {__version__} | {report.tool_count} tool(s) | "
|
|
149
|
+
f"~{report.tokens:,} tokens per request"
|
|
150
|
+
)
|
|
151
|
+
if report.source:
|
|
152
|
+
lines.append(f"source: {report.source}")
|
|
153
|
+
|
|
154
|
+
counts = report.counts()
|
|
155
|
+
summary = " ".join(f"{counts[s.value]} {s.value}" for s in Severity if counts[s.value])
|
|
156
|
+
lines.append("")
|
|
157
|
+
lines.append(
|
|
158
|
+
f" Grade {report.grade:<2} Score {report.score:>3}/100 "
|
|
159
|
+
f"{summary if summary else 'clean'}"
|
|
160
|
+
)
|
|
161
|
+
lines.append("")
|
|
162
|
+
|
|
163
|
+
if not report.findings:
|
|
164
|
+
lines.append(" No findings. Ship it.")
|
|
165
|
+
return "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
ordered = report.sorted_findings()
|
|
168
|
+
serious = [f for f in ordered if f.severity is not Severity.INFO]
|
|
169
|
+
nudges = [f for f in ordered if f.severity is Severity.INFO]
|
|
170
|
+
|
|
171
|
+
if report.kind == "audit":
|
|
172
|
+
# Two sections, because they answer different questions: "will my agent
|
|
173
|
+
# pick the wrong tool?" and "is my setup unsafe?".
|
|
174
|
+
conflicts = [f for f in serious if f.rule.startswith("AGT2")]
|
|
175
|
+
risks = [f for f in serious if not f.rule.startswith("AGT2")]
|
|
176
|
+
if conflicts:
|
|
177
|
+
lines.append(f" CONFLICTS ({len(conflicts)})")
|
|
178
|
+
lines.extend(_finding_lines(conflicts, verbose))
|
|
179
|
+
lines.append("")
|
|
180
|
+
if risks:
|
|
181
|
+
lines.append(f" RISKS ({len(risks)})")
|
|
182
|
+
lines.extend(_serious_lines(risks, verbose))
|
|
183
|
+
else:
|
|
184
|
+
for severity in (Severity.ERROR, Severity.WARNING):
|
|
185
|
+
group = [f for f in serious if f.severity is severity]
|
|
186
|
+
if not group:
|
|
187
|
+
continue
|
|
188
|
+
lines.append(f" {severity.value.upper()}")
|
|
189
|
+
lines.extend(_serious_lines(group, verbose))
|
|
190
|
+
|
|
191
|
+
if nudges:
|
|
192
|
+
rules = len({f.rule for f in nudges})
|
|
193
|
+
lines.append("")
|
|
194
|
+
lines.append(f" NUDGES ({len(nudges)} across {rules} rule(s), no score impact)")
|
|
195
|
+
lines.extend(_nudge_lines(nudges))
|
|
196
|
+
|
|
197
|
+
lines.append("")
|
|
198
|
+
lines.append(" Rule docs: https://github.com/wanjiaqing/agentcheckup#rules")
|
|
199
|
+
return "\n".join(lines)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render_json(report: Report) -> str:
|
|
203
|
+
payload = {
|
|
204
|
+
"version": __version__,
|
|
205
|
+
"source": report.source,
|
|
206
|
+
"server_count": report.server_count,
|
|
207
|
+
"tool_count": report.tool_count,
|
|
208
|
+
"estimated_tokens": report.tokens,
|
|
209
|
+
"score": report.score,
|
|
210
|
+
"grade": report.grade,
|
|
211
|
+
"counts": report.counts(),
|
|
212
|
+
"findings": [f.as_dict() for f in report.sorted_findings()],
|
|
213
|
+
}
|
|
214
|
+
return json.dumps(payload, indent=2, ensure_ascii=False)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def render_sarif(report: Report) -> str:
|
|
218
|
+
"""SARIF 2.1.0 so findings show up in GitHub code scanning for free."""
|
|
219
|
+
uri = report.source or "mcp-tools.json"
|
|
220
|
+
results: list[dict[str, Any]] = []
|
|
221
|
+
for finding in report.sorted_findings():
|
|
222
|
+
result: dict[str, Any] = {
|
|
223
|
+
"ruleId": finding.rule,
|
|
224
|
+
"level": SARIF_LEVEL[finding.severity],
|
|
225
|
+
"message": {"text": finding.message + (f" ({finding.tool})" if finding.tool else "")},
|
|
226
|
+
"locations": [
|
|
227
|
+
{
|
|
228
|
+
"physicalLocation": {
|
|
229
|
+
"artifactLocation": {"uri": uri},
|
|
230
|
+
"region": {"startLine": 1, "startColumn": 1},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
if finding.hint:
|
|
236
|
+
result["fixes"] = []
|
|
237
|
+
result["properties"] = {"hint": finding.hint}
|
|
238
|
+
results.append(result)
|
|
239
|
+
|
|
240
|
+
payload = {
|
|
241
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
242
|
+
"version": "2.1.0",
|
|
243
|
+
"runs": [
|
|
244
|
+
{
|
|
245
|
+
"tool": {
|
|
246
|
+
"driver": {
|
|
247
|
+
"name": "agentcheckup",
|
|
248
|
+
"version": __version__,
|
|
249
|
+
"informationUri": "https://github.com/wanjiaqing/agentcheckup",
|
|
250
|
+
"rules": _rule_metadata(),
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
"results": results,
|
|
254
|
+
}
|
|
255
|
+
],
|
|
256
|
+
}
|
|
257
|
+
return json.dumps(payload, indent=2, ensure_ascii=False)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render_rule_list() -> str:
|
|
261
|
+
lines = ["Tool rules", "----------"]
|
|
262
|
+
for rule in sorted(_rules.TOOL_RULES, key=lambda r: r.id):
|
|
263
|
+
lines.append(f" {rule.id} {rule.severity.value:<7} {rule.title}")
|
|
264
|
+
lines.append("")
|
|
265
|
+
lines.append("Suite rules (need the whole manifest)")
|
|
266
|
+
lines.append("------------------------------------")
|
|
267
|
+
for rule in sorted(_rules.SUITE_RULES, key=lambda r: r.id):
|
|
268
|
+
lines.append(f" {rule.id} {rule.severity.value:<7} {rule.title}")
|
|
269
|
+
lines.append("")
|
|
270
|
+
lines.append("Config rules")
|
|
271
|
+
lines.append("------------")
|
|
272
|
+
for rule in sorted(_rules.CONFIG_RULES, key=lambda r: r.id):
|
|
273
|
+
lines.append(f" {rule.id} {rule.severity.value:<7} {rule.title}")
|
|
274
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Rule registry bootstrap.
|
|
2
|
+
|
|
3
|
+
Importing this package registers every rule. Order matters only for output
|
|
4
|
+
grouping, not for correctness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from . import config_rules, docs, injection, naming, safety, schema # noqa: F401
|
|
10
|
+
from .base import CONFIG_RULES, SUITE_RULES, TOOL_RULES
|
|
11
|
+
|
|
12
|
+
__all__ = ["CONFIG_RULES", "SUITE_RULES", "TOOL_RULES"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Rule protocol and registry.
|
|
2
|
+
|
|
3
|
+
Adding a rule should be a single small file plus a decorator - that is the
|
|
4
|
+
whole point, because the fastest way to grow an OSS project is to leave obvious
|
|
5
|
+
room for other people's pull requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import NamedTuple, Protocol
|
|
12
|
+
|
|
13
|
+
from ..models import Finding, Severity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Rule(Protocol):
|
|
17
|
+
id: str
|
|
18
|
+
severity: Severity
|
|
19
|
+
title: str
|
|
20
|
+
|
|
21
|
+
def check(self, subject): # noqa: ANN001 - Tool or ServerConfig
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
TOOL_RULES: list[Rule] = []
|
|
26
|
+
CONFIG_RULES: list[Rule] = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SuiteRule(NamedTuple):
|
|
30
|
+
"""A rule that needs the whole manifest, so it cannot live in a single file."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
severity: Severity
|
|
34
|
+
title: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Documented here so ``agentcheckup rules`` and the SARIF catalogue stay complete.
|
|
38
|
+
SUITE_RULES: list[SuiteRule] = [
|
|
39
|
+
SuiteRule("AGT004", Severity.ERROR, "Tool name is duplicated"),
|
|
40
|
+
SuiteRule("AGT105", Severity.INFO, "Server did not return a tool list"),
|
|
41
|
+
SuiteRule("AGT201", Severity.ERROR, "Same tool name exposed by two servers"),
|
|
42
|
+
SuiteRule("AGT202", Severity.WARNING, "Two servers expose the same name once normalised"),
|
|
43
|
+
SuiteRule("AGT203", Severity.WARNING, "Two servers expose overlapping tools"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _make_rule(
|
|
48
|
+
rule_id: str,
|
|
49
|
+
sev: Severity,
|
|
50
|
+
rule_title: str,
|
|
51
|
+
fn: Callable,
|
|
52
|
+
doc: str | None,
|
|
53
|
+
) -> Rule:
|
|
54
|
+
class _FnRule:
|
|
55
|
+
def check(self, subject): # noqa: ANN001
|
|
56
|
+
result = fn(subject)
|
|
57
|
+
if result is None:
|
|
58
|
+
return []
|
|
59
|
+
if isinstance(result, Finding):
|
|
60
|
+
return [result]
|
|
61
|
+
return list(result)
|
|
62
|
+
|
|
63
|
+
_FnRule.id = rule_id
|
|
64
|
+
_FnRule.severity = sev
|
|
65
|
+
_FnRule.title = rule_title
|
|
66
|
+
_FnRule.__doc__ = doc or fn.__doc__
|
|
67
|
+
return _FnRule()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tool_rule(rule_id: str, severity: Severity, title: str) -> Callable:
|
|
71
|
+
"""Register a tool-level rule.
|
|
72
|
+
|
|
73
|
+
The decorated function takes a :class:`Tool` and returns either a single
|
|
74
|
+
:class:`Finding`, an iterable of them, or ``None``.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def decorator(fn: Callable) -> Callable:
|
|
78
|
+
TOOL_RULES.append(_make_rule(rule_id, severity, title, fn, fn.__doc__))
|
|
79
|
+
return fn
|
|
80
|
+
|
|
81
|
+
return decorator
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def config_rule(rule_id: str, severity: Severity, title: str) -> Callable:
|
|
85
|
+
"""Register a client-config level rule (operates on ServerConfig)."""
|
|
86
|
+
|
|
87
|
+
def decorator(fn: Callable) -> Callable:
|
|
88
|
+
CONFIG_RULES.append(_make_rule(rule_id, severity, title, fn, fn.__doc__))
|
|
89
|
+
return fn
|
|
90
|
+
|
|
91
|
+
return decorator
|