agent-capmap 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.
- agent_capmap-0.1.0.dist-info/METADATA +295 -0
- agent_capmap-0.1.0.dist-info/RECORD +48 -0
- agent_capmap-0.1.0.dist-info/WHEEL +4 -0
- agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
- agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentcapmap/__init__.py +3 -0
- agentcapmap/__main__.py +5 -0
- agentcapmap/baseline.py +125 -0
- agentcapmap/classify/__init__.py +221 -0
- agentcapmap/classify/cache.py +74 -0
- agentcapmap/classify/prompt.py +160 -0
- agentcapmap/classify/provider.py +122 -0
- agentcapmap/classify/reference.py +115 -0
- agentcapmap/classify/reference_labels.json +6135 -0
- agentcapmap/cli.py +392 -0
- agentcapmap/diff.py +103 -0
- agentcapmap/discovery/__init__.py +209 -0
- agentcapmap/discovery/_common.py +297 -0
- agentcapmap/discovery/claude_code.py +258 -0
- agentcapmap/discovery/claude_desktop.py +28 -0
- agentcapmap/discovery/cursor.py +33 -0
- agentcapmap/discovery/mcp_json.py +29 -0
- agentcapmap/discovery/vscode.py +65 -0
- agentcapmap/enumerate/__init__.py +278 -0
- agentcapmap/enumerate/hashing.py +37 -0
- agentcapmap/enumerate/http.py +313 -0
- agentcapmap/enumerate/netguard.py +60 -0
- agentcapmap/enumerate/stdio.py +273 -0
- agentcapmap/enumerate/transport.py +86 -0
- agentcapmap/evaluation.py +498 -0
- agentcapmap/findings.py +129 -0
- agentcapmap/graph/__init__.py +264 -0
- agentcapmap/graph/server_attrs.py +164 -0
- agentcapmap/limits.py +35 -0
- agentcapmap/models.py +186 -0
- agentcapmap/policies/default.yaml +113 -0
- agentcapmap/policy/__init__.py +24 -0
- agentcapmap/policy/engine.py +229 -0
- agentcapmap/policy/loader.py +223 -0
- agentcapmap/py.typed +0 -0
- agentcapmap/redact.py +268 -0
- agentcapmap/report/__init__.py +36 -0
- agentcapmap/report/_text.py +62 -0
- agentcapmap/report/markdown.py +152 -0
- agentcapmap/report/sarif.py +209 -0
- agentcapmap/schemas/policy.schema.json +172 -0
- agentcapmap/schemas/tool-tags.schema.json +57 -0
- agentcapmap/taxonomy.py +56 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""JSON-RPC transport interface and the method allowlist.
|
|
2
|
+
|
|
3
|
+
Design rule: agent-capmap never executes tools on enumerated servers. Every outgoing
|
|
4
|
+
request goes through `check_allowed`, so a `tools/call` (or anything else) cannot be
|
|
5
|
+
sent even by mistake.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
ALLOWED_REQUESTS = frozenset({"initialize", "tools/list"})
|
|
14
|
+
ALLOWED_NOTIFICATIONS = frozenset({"notifications/initialized"})
|
|
15
|
+
|
|
16
|
+
JSONRPC_METHOD_NOT_FOUND = -32601
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TransportError(Exception):
|
|
20
|
+
"""The connection failed or the server misbehaved at the protocol level."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RequestTimeout(TransportError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthRequired(TransportError):
|
|
28
|
+
def __init__(self, message: str, www_authenticate: str | None = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.www_authenticate = www_authenticate
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ForbiddenMethod(RuntimeError):
|
|
34
|
+
"""Raised before sending anything outside the allowlist. A bug, not a server problem."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MCPError(Exception):
|
|
38
|
+
"""The server returned a JSON-RPC error object."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, code: int, message: str) -> None:
|
|
41
|
+
super().__init__(f"JSON-RPC error {code}: {message}")
|
|
42
|
+
self.code = code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_allowed(method: str, *, notification: bool = False) -> None:
|
|
46
|
+
allowed = ALLOWED_NOTIFICATIONS if notification else ALLOWED_REQUESTS
|
|
47
|
+
if method not in allowed:
|
|
48
|
+
raise ForbiddenMethod(f"refusing to send {method!r}: agent-capmap never invokes tools")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Transport(Protocol):
|
|
52
|
+
def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
53
|
+
"""Send a request and return its `result`; raise MCPError on a JSON-RPC error."""
|
|
54
|
+
|
|
55
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
56
|
+
"""Send a notification (no response expected)."""
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
"""Release the connection or terminate the server process."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IdCounter:
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._it = itertools.count(1)
|
|
65
|
+
|
|
66
|
+
def next(self) -> int:
|
|
67
|
+
return next(self._it)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unwrap_response(message: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
if "error" in message:
|
|
72
|
+
err = message["error"] if isinstance(message["error"], dict) else {}
|
|
73
|
+
raise MCPError(int(err.get("code", 0)), str(err.get("message", "unknown error")))
|
|
74
|
+
result = message.get("result")
|
|
75
|
+
if not isinstance(result, dict):
|
|
76
|
+
raise TransportError("response 'result' is not an object")
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_response_to(message: Any, request_id: int) -> bool:
|
|
81
|
+
return (
|
|
82
|
+
isinstance(message, dict)
|
|
83
|
+
and message.get("id") == request_id
|
|
84
|
+
and "method" not in message
|
|
85
|
+
and ("result" in message or "error" in message)
|
|
86
|
+
)
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Classifier evaluation against hand-labeled real MCP servers (eval/).
|
|
2
|
+
|
|
3
|
+
Metrics:
|
|
4
|
+
- per axis: accuracy, per-value precision/recall/F1, macro-F1, and capability detection
|
|
5
|
+
(any non-default value vs default) precision/recall/F1;
|
|
6
|
+
- exact match: all six tags right;
|
|
7
|
+
- confusion matrices (egress and execution are reported in results.md);
|
|
8
|
+
- suspicious-description precision/recall;
|
|
9
|
+
- rule-level agreement: the default policy run on realistic agent setups with
|
|
10
|
+
predicted tags vs ground-truth tags (gives the false-positive rate on clean agents);
|
|
11
|
+
- planted-risk recall: classifier-dependent findings planted in fixtures/.
|
|
12
|
+
|
|
13
|
+
Ground truth is never produced by the LLM. Labels marked "draft" (pre-filled, not yet
|
|
14
|
+
human-reviewed) make every result PROVISIONAL.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
|
|
29
|
+
from agentcapmap.enumerate.hashing import server_definition_hash, tool_definition_hash
|
|
30
|
+
from agentcapmap.findings import Severity
|
|
31
|
+
from agentcapmap.models import (
|
|
32
|
+
Agent,
|
|
33
|
+
AuthHint,
|
|
34
|
+
AuthMethod,
|
|
35
|
+
ClassificationStatus,
|
|
36
|
+
EnumerationResult,
|
|
37
|
+
EnumerationStatus,
|
|
38
|
+
Inventory,
|
|
39
|
+
Server,
|
|
40
|
+
SourceLocation,
|
|
41
|
+
ToolClassification,
|
|
42
|
+
ToolDefinition,
|
|
43
|
+
ToolTags,
|
|
44
|
+
Transport,
|
|
45
|
+
)
|
|
46
|
+
from agentcapmap.policy import Policy, evaluate
|
|
47
|
+
from agentcapmap.taxonomy import AXES, DEFAULTS, axis_values
|
|
48
|
+
|
|
49
|
+
UNCLASSIFIED = "unclassified"
|
|
50
|
+
LabelStatus = Literal["unlabeled", "draft", "reviewed"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EvalDataError(ValueError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- label files -----------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ToolLabel(BaseModel):
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
tags: ToolTags
|
|
64
|
+
evidence: dict[str, str] = Field(default_factory=dict)
|
|
65
|
+
suspicious: bool = False
|
|
66
|
+
notes: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LabeledTool(BaseModel):
|
|
70
|
+
model_config = ConfigDict(extra="forbid")
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
description: str = ""
|
|
74
|
+
input_schema: dict[str, Any] = Field(default_factory=dict)
|
|
75
|
+
definition_hash: str
|
|
76
|
+
label: ToolLabel | None = None
|
|
77
|
+
|
|
78
|
+
def definition(self) -> ToolDefinition:
|
|
79
|
+
return ToolDefinition(
|
|
80
|
+
name=self.name,
|
|
81
|
+
description=self.description,
|
|
82
|
+
input_schema=self.input_schema,
|
|
83
|
+
definition_hash=self.definition_hash,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Labeling(BaseModel):
|
|
88
|
+
model_config = ConfigDict(extra="forbid")
|
|
89
|
+
|
|
90
|
+
status: LabelStatus
|
|
91
|
+
labeled_by: str | None = None
|
|
92
|
+
labeled_at: str | None = None
|
|
93
|
+
reviewed_by: str | None = None
|
|
94
|
+
reviewed_at: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LabeledServer(BaseModel):
|
|
98
|
+
model_config = ConfigDict(extra="forbid")
|
|
99
|
+
|
|
100
|
+
id: str
|
|
101
|
+
category: str
|
|
102
|
+
source: str
|
|
103
|
+
version: str
|
|
104
|
+
deprecated: bool = False
|
|
105
|
+
captured: dict[str, Any]
|
|
106
|
+
labeling: Labeling
|
|
107
|
+
tools: list[LabeledTool]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_labeled_servers(directory: Path) -> list[LabeledServer]:
|
|
111
|
+
servers: list[LabeledServer] = []
|
|
112
|
+
for path in sorted(directory.glob("*.json")):
|
|
113
|
+
server = LabeledServer.model_validate_json(path.read_text(encoding="utf-8"))
|
|
114
|
+
for tool in server.tools:
|
|
115
|
+
expected = tool_definition_hash(tool.name, tool.description, tool.input_schema)
|
|
116
|
+
if expected != tool.definition_hash:
|
|
117
|
+
raise EvalDataError(f"{path.name}: definition of {tool.name!r} was edited")
|
|
118
|
+
if server.labeling.status != "unlabeled" and tool.label is None:
|
|
119
|
+
raise EvalDataError(f"{path.name}: {tool.name!r} has no label")
|
|
120
|
+
servers.append(server)
|
|
121
|
+
return servers
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def label_manifest(directory: Path) -> dict[str, str]:
|
|
125
|
+
"""sha256 of each label file, for freezing the label set."""
|
|
126
|
+
return {
|
|
127
|
+
path.name: hashlib.sha256(path.read_bytes()).hexdigest()
|
|
128
|
+
for path in sorted(directory.glob("*.json"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def manifest_digest(manifest: dict[str, str]) -> str:
|
|
133
|
+
return hashlib.sha256(json.dumps(manifest, sort_keys=True).encode()).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- per-axis metrics --------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class ClassMetrics:
|
|
141
|
+
precision: float | None
|
|
142
|
+
recall: float | None
|
|
143
|
+
f1: float | None
|
|
144
|
+
support: int
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class AxisMetrics:
|
|
149
|
+
axis: str
|
|
150
|
+
accuracy: float
|
|
151
|
+
macro_f1: float | None
|
|
152
|
+
detection: ClassMetrics # non-default vs default
|
|
153
|
+
per_value: dict[str, ClassMetrics]
|
|
154
|
+
confusion: dict[str, dict[str, int]] # truth -> predicted -> count
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _ratio(num: int, den: int) -> float | None:
|
|
158
|
+
return num / den if den else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _prf(tp: int, fp: int, fn: int) -> ClassMetrics:
|
|
162
|
+
precision, recall = _ratio(tp, tp + fp), _ratio(tp, tp + fn)
|
|
163
|
+
f1 = (
|
|
164
|
+
2 * precision * recall / (precision + recall)
|
|
165
|
+
if precision is not None and recall is not None and (precision + recall) > 0
|
|
166
|
+
else (0.0 if precision is not None and recall is not None else None)
|
|
167
|
+
)
|
|
168
|
+
return ClassMetrics(precision, recall, f1, tp + fn)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _key(value: Any) -> str:
|
|
172
|
+
return str(value).lower()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def axis_metrics(axis: str, pairs: list[tuple[Any, Any]]) -> AxisMetrics:
|
|
176
|
+
"""`pairs` are (truth, predicted); predicted may be UNCLASSIFIED."""
|
|
177
|
+
values = [_key(v) for v in axis_values(axis)]
|
|
178
|
+
default = _key(DEFAULTS[axis])
|
|
179
|
+
confusion = {v: dict.fromkeys([*values, UNCLASSIFIED], 0) for v in values}
|
|
180
|
+
for truth, pred in pairs:
|
|
181
|
+
confusion[_key(truth)][_key(pred)] += 1
|
|
182
|
+
total = len(pairs)
|
|
183
|
+
correct = sum(confusion[v][v] for v in values)
|
|
184
|
+
per_value = {}
|
|
185
|
+
for v in values:
|
|
186
|
+
tp = confusion[v][v]
|
|
187
|
+
fp = sum(confusion[t][v] for t in values if t != v)
|
|
188
|
+
fn = sum(confusion[v].values()) - tp
|
|
189
|
+
per_value[v] = _prf(tp, fp, fn)
|
|
190
|
+
f1s = [m.f1 for m in per_value.values() if m.support and m.f1 is not None]
|
|
191
|
+
det_tp = sum(
|
|
192
|
+
1 for t, p in pairs if _key(t) != default and _key(p) not in (default, UNCLASSIFIED)
|
|
193
|
+
)
|
|
194
|
+
det_fp = sum(
|
|
195
|
+
1 for t, p in pairs if _key(t) == default and _key(p) not in (default, UNCLASSIFIED)
|
|
196
|
+
)
|
|
197
|
+
det_fn = sum(1 for t, p in pairs if _key(t) != default and _key(p) in (default, UNCLASSIFIED))
|
|
198
|
+
return AxisMetrics(
|
|
199
|
+
axis=axis,
|
|
200
|
+
accuracy=correct / total if total else 0.0,
|
|
201
|
+
macro_f1=sum(f1s) / len(f1s) if f1s else None,
|
|
202
|
+
detection=_prf(det_tp, det_fp, det_fn),
|
|
203
|
+
per_value=per_value,
|
|
204
|
+
confusion=confusion,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class Disagreement:
|
|
210
|
+
server: str
|
|
211
|
+
tool: str
|
|
212
|
+
axis: str
|
|
213
|
+
truth: str
|
|
214
|
+
predicted: str
|
|
215
|
+
evidence: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class ClassifierReport:
|
|
220
|
+
tools: int
|
|
221
|
+
unclassified: int
|
|
222
|
+
exact_match: float
|
|
223
|
+
axes: dict[str, AxisMetrics]
|
|
224
|
+
suspicious: ClassMetrics
|
|
225
|
+
disagreements: list[Disagreement] = field(default_factory=list)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def score_classifier(
|
|
229
|
+
servers: Iterable[LabeledServer], predictions: dict[str, ToolClassification]
|
|
230
|
+
) -> ClassifierReport:
|
|
231
|
+
"""Compare predictions (by definition hash) with labels."""
|
|
232
|
+
pairs: dict[str, list[tuple[Any, Any]]] = {axis: [] for axis in AXES}
|
|
233
|
+
exact = unclassified = total = 0
|
|
234
|
+
sus_tp = sus_fp = sus_fn = 0
|
|
235
|
+
disagreements: list[Disagreement] = []
|
|
236
|
+
for server in servers:
|
|
237
|
+
for tool in server.tools:
|
|
238
|
+
if tool.label is None:
|
|
239
|
+
continue
|
|
240
|
+
total += 1
|
|
241
|
+
pred = predictions.get(tool.definition_hash)
|
|
242
|
+
pred_tags = (
|
|
243
|
+
pred.tags if pred and pred.status is ClassificationStatus.CLASSIFIED else None
|
|
244
|
+
)
|
|
245
|
+
if pred_tags is None:
|
|
246
|
+
unclassified += 1
|
|
247
|
+
all_right = pred_tags is not None
|
|
248
|
+
for axis in AXES:
|
|
249
|
+
truth = getattr(tool.label.tags, axis)
|
|
250
|
+
predicted = getattr(pred_tags, axis) if pred_tags is not None else UNCLASSIFIED
|
|
251
|
+
pairs[axis].append((truth, predicted))
|
|
252
|
+
if _key(truth) != _key(predicted):
|
|
253
|
+
all_right = False
|
|
254
|
+
evidence = pred.evidence.get(axis, "") if pred else ""
|
|
255
|
+
disagreements.append(
|
|
256
|
+
Disagreement(
|
|
257
|
+
server.id, tool.name, axis, _key(truth), _key(predicted), evidence
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
exact += all_right
|
|
261
|
+
flagged = bool(
|
|
262
|
+
pred and pred.suspicious_description and pred.suspicious_description.flag
|
|
263
|
+
)
|
|
264
|
+
sus_tp += tool.label.suspicious and flagged
|
|
265
|
+
sus_fp += (not tool.label.suspicious) and flagged
|
|
266
|
+
sus_fn += tool.label.suspicious and not flagged
|
|
267
|
+
return ClassifierReport(
|
|
268
|
+
tools=total,
|
|
269
|
+
unclassified=unclassified,
|
|
270
|
+
exact_match=exact / total if total else 0.0,
|
|
271
|
+
axes={axis: axis_metrics(axis, pairs[axis]) for axis in AXES},
|
|
272
|
+
suspicious=_prf(sus_tp, sus_fp, sus_fn),
|
|
273
|
+
disagreements=disagreements,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- rule-level agreement on realistic agents --------------------------------------------
|
|
278
|
+
|
|
279
|
+
Classify = Callable[[ToolDefinition], ToolClassification]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def truth_classification(label: ToolLabel) -> ToolClassification:
|
|
283
|
+
return ToolClassification(
|
|
284
|
+
status=ClassificationStatus.CLASSIFIED,
|
|
285
|
+
tags=label.tags,
|
|
286
|
+
confidence=1.0,
|
|
287
|
+
evidence=label.evidence,
|
|
288
|
+
suspicious_description=None if not label.suspicious else _suspicious("labeled"),
|
|
289
|
+
model="ground-truth",
|
|
290
|
+
taxonomy_version="1",
|
|
291
|
+
prompt_version="labels",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _suspicious(reason: str) -> Any:
|
|
296
|
+
from agentcapmap.models import SuspiciousDescription
|
|
297
|
+
|
|
298
|
+
return SuspiciousDescription(flag=True, reason=reason)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_agent(
|
|
302
|
+
agent_id: str,
|
|
303
|
+
servers: list[LabeledServer],
|
|
304
|
+
catalog: dict[str, dict[str, Any]],
|
|
305
|
+
classification_for: Callable[[LabeledTool], ToolClassification],
|
|
306
|
+
) -> Agent:
|
|
307
|
+
built: list[Server] = []
|
|
308
|
+
for line, server in enumerate(servers, start=1):
|
|
309
|
+
spec = catalog[server.id]
|
|
310
|
+
tools = []
|
|
311
|
+
for tool in server.tools:
|
|
312
|
+
definition = tool.definition()
|
|
313
|
+
definition.classification = classification_for(tool)
|
|
314
|
+
tools.append(definition)
|
|
315
|
+
remote = "url" in spec
|
|
316
|
+
built.append(
|
|
317
|
+
Server(
|
|
318
|
+
name=server.id,
|
|
319
|
+
transport=Transport.HTTP if remote else Transport.STDIO,
|
|
320
|
+
command=None if remote else spec["command"],
|
|
321
|
+
args=[] if remote else list(spec["args"]),
|
|
322
|
+
url=spec.get("url"),
|
|
323
|
+
auth=AuthHint(method=AuthMethod.STATIC if spec.get("env") else AuthMethod.NONE),
|
|
324
|
+
location=SourceLocation(path=f"{agent_id}.json", line=line),
|
|
325
|
+
enumeration=EnumerationResult(
|
|
326
|
+
status=EnumerationStatus.OK,
|
|
327
|
+
tools=tools,
|
|
328
|
+
definition_hash=server_definition_hash(t.definition_hash for t in tools),
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return Agent(id=agent_id, client="eval", config_path=f"{agent_id}.json", servers=built)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass
|
|
336
|
+
class RuleAgreement:
|
|
337
|
+
agents: int
|
|
338
|
+
truth_findings: int
|
|
339
|
+
predicted_findings: int
|
|
340
|
+
matched: int
|
|
341
|
+
precision: float | None
|
|
342
|
+
recall: float | None
|
|
343
|
+
clean_agents: int # no critical/high finding under ground truth
|
|
344
|
+
false_alarm_agents: int # of those, agents with a predicted critical/high finding
|
|
345
|
+
per_agent: list[dict[str, Any]] = field(default_factory=list)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def false_positive_rate(self) -> float | None:
|
|
349
|
+
return _ratio(self.false_alarm_agents, self.clean_agents)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _severe(rules: set[tuple[str, str]], policy: Policy) -> set[str]:
|
|
353
|
+
severity = {r.id: r.severity for r in policy.rules}
|
|
354
|
+
return {rule for rule, _sev in rules if severity[rule].rank >= Severity.HIGH.rank}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def rule_agreement(
|
|
358
|
+
agents: dict[str, list[str]],
|
|
359
|
+
servers: dict[str, LabeledServer],
|
|
360
|
+
catalog: dict[str, dict[str, Any]],
|
|
361
|
+
predictions: dict[str, ToolClassification],
|
|
362
|
+
policy: Policy,
|
|
363
|
+
) -> RuleAgreement:
|
|
364
|
+
def predicted(tool: LabeledTool) -> ToolClassification:
|
|
365
|
+
return predictions.get(tool.definition_hash) or ToolClassification(
|
|
366
|
+
status=ClassificationStatus.UNCLASSIFIED,
|
|
367
|
+
error="no prediction",
|
|
368
|
+
taxonomy_version="1",
|
|
369
|
+
prompt_version="?",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def truth(tool: LabeledTool) -> ToolClassification:
|
|
373
|
+
if tool.label is None: # callers skip agents with unlabeled tools
|
|
374
|
+
raise EvalDataError(f"{tool.name!r} has no label")
|
|
375
|
+
return truth_classification(tool.label)
|
|
376
|
+
|
|
377
|
+
truth_total = pred_total = matched = clean = false_alarms = 0
|
|
378
|
+
per_agent = []
|
|
379
|
+
for agent_id, server_ids in sorted(agents.items()):
|
|
380
|
+
members = [servers[s] for s in server_ids if s in servers]
|
|
381
|
+
if len(members) != len(server_ids) or any(
|
|
382
|
+
t.label is None for m in members for t in m.tools
|
|
383
|
+
):
|
|
384
|
+
continue # skip agents whose servers are missing or unlabeled
|
|
385
|
+
found = {}
|
|
386
|
+
for kind, fn in (("truth", truth), ("predicted", predicted)):
|
|
387
|
+
agent = build_agent(agent_id, members, catalog, fn)
|
|
388
|
+
evaluation = evaluate(Inventory(agents=[agent]), policy)
|
|
389
|
+
found[kind] = {(f.rule_id, f.severity.value) for f in evaluation.findings}
|
|
390
|
+
t_rules, p_rules = {r for r, _ in found["truth"]}, {r for r, _ in found["predicted"]}
|
|
391
|
+
truth_total += len(t_rules)
|
|
392
|
+
pred_total += len(p_rules)
|
|
393
|
+
matched += len(t_rules & p_rules)
|
|
394
|
+
if not _severe(found["truth"], policy):
|
|
395
|
+
clean += 1
|
|
396
|
+
false_alarms += bool(_severe(found["predicted"], policy))
|
|
397
|
+
per_agent.append(
|
|
398
|
+
{
|
|
399
|
+
"agent": agent_id,
|
|
400
|
+
"servers": server_ids,
|
|
401
|
+
"truth": sorted(t_rules),
|
|
402
|
+
"predicted": sorted(p_rules),
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
return RuleAgreement(
|
|
406
|
+
agents=len(per_agent),
|
|
407
|
+
truth_findings=truth_total,
|
|
408
|
+
predicted_findings=pred_total,
|
|
409
|
+
matched=matched,
|
|
410
|
+
precision=_ratio(matched, pred_total),
|
|
411
|
+
recall=_ratio(matched, truth_total),
|
|
412
|
+
clean_agents=clean,
|
|
413
|
+
false_alarm_agents=false_alarms,
|
|
414
|
+
per_agent=per_agent,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- planted risks in fixtures/ ------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
# Findings in fixtures/ that depend on the classifier's tags (CAP-006 and CAP-009 do not).
|
|
421
|
+
PLANTED = {
|
|
422
|
+
"trifecta": {"CAP-001"},
|
|
423
|
+
"exec-injection": {"CAP-002", "CAP-003"},
|
|
424
|
+
"no-approval-destructive": {"CAP-003"},
|
|
425
|
+
"poisoned-description": {"CAP-008"},
|
|
426
|
+
"overscoped-token": {"CAP-005"},
|
|
427
|
+
"rug-pull": {"CAP-008"},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@dataclass
|
|
432
|
+
class PlantedRecall:
|
|
433
|
+
planted: int
|
|
434
|
+
detected: int
|
|
435
|
+
per_fixture: dict[str, dict[str, list[str]]]
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def recall(self) -> float | None:
|
|
439
|
+
return _ratio(self.detected, self.planted)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def fixture_inventory(directory: Path, classify: Classify) -> Inventory:
|
|
443
|
+
"""A fixture's config + tool definitions, classified by `classify` (not by its tags)."""
|
|
444
|
+
from agentcapmap.discovery import parse_config_file
|
|
445
|
+
|
|
446
|
+
agent, error = parse_config_file(directory / "mcp.json", root=directory, explicit=True)
|
|
447
|
+
if error or agent is None:
|
|
448
|
+
raise EvalDataError(f"{directory.name}: {error.message if error else 'no agent'}")
|
|
449
|
+
spec = json.loads((directory / "tools.json").read_text(encoding="utf-8"))
|
|
450
|
+
for server in agent.servers:
|
|
451
|
+
server_spec = spec["servers"][server.name]
|
|
452
|
+
tools = []
|
|
453
|
+
for t in server_spec["tools"]:
|
|
454
|
+
schema = t.get("inputSchema", {"type": "object"})
|
|
455
|
+
definition = ToolDefinition(
|
|
456
|
+
name=t["name"],
|
|
457
|
+
description=t.get("description", ""),
|
|
458
|
+
input_schema=schema,
|
|
459
|
+
definition_hash=tool_definition_hash(t["name"], t.get("description", ""), schema),
|
|
460
|
+
)
|
|
461
|
+
definition.classification = classify(definition)
|
|
462
|
+
tools.append(definition)
|
|
463
|
+
server.enumeration = EnumerationResult(status=EnumerationStatus.OK, tools=tools)
|
|
464
|
+
if auth := server_spec.get("auth"):
|
|
465
|
+
server.auth = AuthHint(method=AuthMethod(auth))
|
|
466
|
+
return Inventory(agents=[agent])
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def planted_recall(fixtures: Path, classify: Classify, policy: Policy) -> PlantedRecall:
|
|
470
|
+
planted = detected = 0
|
|
471
|
+
per_fixture = {}
|
|
472
|
+
for name, rules in sorted(PLANTED.items()):
|
|
473
|
+
found = {
|
|
474
|
+
f.rule_id
|
|
475
|
+
for f in evaluate(fixture_inventory(fixtures / name, classify), policy).findings
|
|
476
|
+
}
|
|
477
|
+
hits = rules & found
|
|
478
|
+
planted += len(rules)
|
|
479
|
+
detected += len(hits)
|
|
480
|
+
per_fixture[name] = {
|
|
481
|
+
"planted": sorted(rules),
|
|
482
|
+
"detected": sorted(hits),
|
|
483
|
+
"all": sorted(found),
|
|
484
|
+
}
|
|
485
|
+
return PlantedRecall(planted, detected, per_fixture)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def ai_reviewed(servers: Iterable[LabeledServer]) -> int:
|
|
489
|
+
"""Reviewed files whose reviewer is an AI model (`reviewed_by` starts with "AI:")."""
|
|
490
|
+
return sum(
|
|
491
|
+
1
|
|
492
|
+
for s in servers
|
|
493
|
+
if s.labeling.status == "reviewed" and (s.labeling.reviewed_by or "").startswith("AI:")
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def label_status(servers: Iterable[LabeledServer]) -> Counter[str]:
|
|
498
|
+
return Counter(s.labeling.status for s in servers)
|
agentcapmap/findings.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Evaluation output: per-agent capabilities, findings with evidence, coverage gaps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from agentcapmap import __version__
|
|
11
|
+
from agentcapmap.models import SourceLocation
|
|
12
|
+
|
|
13
|
+
EVALUATION_SCHEMA_VERSION = "1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Severity(StrEnum):
|
|
17
|
+
CRITICAL = "critical"
|
|
18
|
+
HIGH = "high"
|
|
19
|
+
MEDIUM = "medium"
|
|
20
|
+
LOW = "low"
|
|
21
|
+
INFO = "info"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def rank(self) -> int:
|
|
25
|
+
return _SEVERITY_RANK[self]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_SEVERITY_RANK = {
|
|
29
|
+
Severity.CRITICAL: 4,
|
|
30
|
+
Severity.HIGH: 3,
|
|
31
|
+
Severity.MEDIUM: 2,
|
|
32
|
+
Severity.LOW: 1,
|
|
33
|
+
Severity.INFO: 0,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolRef(BaseModel):
|
|
38
|
+
server: str
|
|
39
|
+
tool: str
|
|
40
|
+
# Phrase from the tool definition that justified the tag, when there is one.
|
|
41
|
+
evidence: str | None = None
|
|
42
|
+
# True when the phrase was not found verbatim in the tool definition.
|
|
43
|
+
unverified: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentCapabilities(BaseModel):
|
|
47
|
+
"""Union of non-default capabilities across an agent's tools, with provenance."""
|
|
48
|
+
|
|
49
|
+
agent_id: str
|
|
50
|
+
client: str
|
|
51
|
+
config_path: str
|
|
52
|
+
# "egress=external" -> the tools that contribute it.
|
|
53
|
+
capabilities: dict[str, list[ToolRef]] = Field(default_factory=dict)
|
|
54
|
+
tools_total: int = 0
|
|
55
|
+
tools_assessed: int = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FindingEvidence(BaseModel):
|
|
59
|
+
server: str
|
|
60
|
+
tool: str | None = None
|
|
61
|
+
# The fields that matched and their actual values, e.g. {"egress": "external"}.
|
|
62
|
+
matched: dict[str, str | bool] = Field(default_factory=dict)
|
|
63
|
+
# Source phrases from the tool definition (or config) backing each matched field.
|
|
64
|
+
phrases: dict[str, str] = Field(default_factory=dict)
|
|
65
|
+
unverified: list[str] = Field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerLocation(BaseModel):
|
|
69
|
+
server: str
|
|
70
|
+
path: str
|
|
71
|
+
line: int | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Finding(BaseModel):
|
|
75
|
+
rule_id: str
|
|
76
|
+
severity: Severity
|
|
77
|
+
message: str
|
|
78
|
+
maps_to: list[str] = Field(default_factory=list)
|
|
79
|
+
agent_id: str
|
|
80
|
+
config_path: str
|
|
81
|
+
location: SourceLocation | None = None
|
|
82
|
+
# Where each involved server is defined (the first one is `location`).
|
|
83
|
+
server_locations: list[ServerLocation] = Field(default_factory=list)
|
|
84
|
+
servers: list[str]
|
|
85
|
+
tools: list[str] = Field(default_factory=list) # "server/tool"
|
|
86
|
+
evidence: list[FindingEvidence] = Field(default_factory=list)
|
|
87
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
88
|
+
# Stable identity across runs (for SARIF partialFingerprints and suppressions).
|
|
89
|
+
fingerprint: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NotAssessed(BaseModel):
|
|
93
|
+
agent_id: str
|
|
94
|
+
server: str
|
|
95
|
+
tool: str | None = None
|
|
96
|
+
reason: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SkippedRule(BaseModel):
|
|
100
|
+
rule_id: str
|
|
101
|
+
reason: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RuleInfo(BaseModel):
|
|
105
|
+
id: str
|
|
106
|
+
severity: Severity
|
|
107
|
+
message: str
|
|
108
|
+
maps_to: list[str] = Field(default_factory=list)
|
|
109
|
+
enabled: bool = True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PolicyInfo(BaseModel):
|
|
113
|
+
source: str
|
|
114
|
+
version: int
|
|
115
|
+
taxonomy_version: str
|
|
116
|
+
sha256: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Evaluation(BaseModel):
|
|
120
|
+
schema_version: str = EVALUATION_SCHEMA_VERSION
|
|
121
|
+
tool_version: str = __version__
|
|
122
|
+
policy: PolicyInfo
|
|
123
|
+
rules: list[RuleInfo] = Field(default_factory=list)
|
|
124
|
+
baseline_used: bool = False
|
|
125
|
+
agents: list[AgentCapabilities] = Field(default_factory=list)
|
|
126
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
127
|
+
not_assessed: list[NotAssessed] = Field(default_factory=list)
|
|
128
|
+
skipped_rules: list[SkippedRule] = Field(default_factory=list)
|
|
129
|
+
summary: dict[str, int] = Field(default_factory=dict)
|