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.
Files changed (48) hide show
  1. agent_capmap-0.1.0.dist-info/METADATA +295 -0
  2. agent_capmap-0.1.0.dist-info/RECORD +48 -0
  3. agent_capmap-0.1.0.dist-info/WHEEL +4 -0
  4. agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
  5. agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. agentcapmap/__init__.py +3 -0
  7. agentcapmap/__main__.py +5 -0
  8. agentcapmap/baseline.py +125 -0
  9. agentcapmap/classify/__init__.py +221 -0
  10. agentcapmap/classify/cache.py +74 -0
  11. agentcapmap/classify/prompt.py +160 -0
  12. agentcapmap/classify/provider.py +122 -0
  13. agentcapmap/classify/reference.py +115 -0
  14. agentcapmap/classify/reference_labels.json +6135 -0
  15. agentcapmap/cli.py +392 -0
  16. agentcapmap/diff.py +103 -0
  17. agentcapmap/discovery/__init__.py +209 -0
  18. agentcapmap/discovery/_common.py +297 -0
  19. agentcapmap/discovery/claude_code.py +258 -0
  20. agentcapmap/discovery/claude_desktop.py +28 -0
  21. agentcapmap/discovery/cursor.py +33 -0
  22. agentcapmap/discovery/mcp_json.py +29 -0
  23. agentcapmap/discovery/vscode.py +65 -0
  24. agentcapmap/enumerate/__init__.py +278 -0
  25. agentcapmap/enumerate/hashing.py +37 -0
  26. agentcapmap/enumerate/http.py +313 -0
  27. agentcapmap/enumerate/netguard.py +60 -0
  28. agentcapmap/enumerate/stdio.py +273 -0
  29. agentcapmap/enumerate/transport.py +86 -0
  30. agentcapmap/evaluation.py +498 -0
  31. agentcapmap/findings.py +129 -0
  32. agentcapmap/graph/__init__.py +264 -0
  33. agentcapmap/graph/server_attrs.py +164 -0
  34. agentcapmap/limits.py +35 -0
  35. agentcapmap/models.py +186 -0
  36. agentcapmap/policies/default.yaml +113 -0
  37. agentcapmap/policy/__init__.py +24 -0
  38. agentcapmap/policy/engine.py +229 -0
  39. agentcapmap/policy/loader.py +223 -0
  40. agentcapmap/py.typed +0 -0
  41. agentcapmap/redact.py +268 -0
  42. agentcapmap/report/__init__.py +36 -0
  43. agentcapmap/report/_text.py +62 -0
  44. agentcapmap/report/markdown.py +152 -0
  45. agentcapmap/report/sarif.py +209 -0
  46. agentcapmap/schemas/policy.schema.json +172 -0
  47. agentcapmap/schemas/tool-tags.schema.json +57 -0
  48. 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)
@@ -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)