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
agentcapmap/cli.py ADDED
@@ -0,0 +1,392 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from pydantic import BaseModel, ValidationError
12
+
13
+ from agentcapmap import __version__
14
+ from agentcapmap.baseline import Baseline, BaselineError, create_baseline, load_baseline
15
+ from agentcapmap.classify import Classifier, classify_inventory
16
+ from agentcapmap.classify.cache import ClassificationCache
17
+ from agentcapmap.classify.provider import AnthropicProvider
18
+ from agentcapmap.diff import diff_evaluations, render_markdown, should_fail_diff
19
+ from agentcapmap.discovery import discover, user_config_paths
20
+ from agentcapmap.enumerate import DEFAULT_TIMEOUT, enumerate_inventory
21
+ from agentcapmap.findings import Evaluation, Severity
22
+ from agentcapmap.models import Inventory
23
+ from agentcapmap.policy import PolicyError, evaluate, load_policy
24
+ from agentcapmap.redact import RedactingFilter, get_logger, redact_text
25
+ from agentcapmap.report import Format, render, should_fail
26
+
27
+ log = get_logger("agentcapmap")
28
+
29
+ ENUMERATE_WARNING = (
30
+ "warning: --enumerate launches the stdio server commands found in these configs and "
31
+ "connects to their URLs. Only enumerate configs you trust."
32
+ )
33
+ _MAX_INVENTORY_BYTES = 64 * 1024 * 1024
34
+
35
+ # Exit codes: 0 success; 1 findings met --fail-on (or something was not assessed with
36
+ # --fail-on-not-assessed); 2 usage or input error.
37
+ EXIT_FINDINGS = 1
38
+ EXIT_ERROR = 2
39
+
40
+
41
+ class CliError(Exception):
42
+ pass
43
+
44
+
45
+ def _setup_logging(verbosity: int) -> None:
46
+ handler = logging.StreamHandler(sys.stderr)
47
+ handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
48
+ handler.addFilter(RedactingFilter())
49
+ root = logging.getLogger("agentcapmap")
50
+ root.handlers[:] = [handler]
51
+ root.setLevel(logging.WARNING - 10 * min(verbosity, 2))
52
+ root.propagate = False
53
+
54
+
55
+ def _add_common(p: argparse.ArgumentParser) -> None:
56
+ p.add_argument("-o", "--output", type=Path, help="write JSON here instead of stdout")
57
+ p.add_argument("-v", "--verbose", action="count", default=0, help="-v info, -vv debug")
58
+
59
+
60
+ def _add_classify_options(p: argparse.ArgumentParser) -> None:
61
+ p.add_argument(
62
+ "--offline",
63
+ action="store_true",
64
+ help="use cached classifications only; never call the LLM",
65
+ )
66
+ p.add_argument("--cache-dir", type=Path, help="classification cache directory")
67
+ p.add_argument("--model", help="classifier model (default: $AGENTCAPMAP_MODEL or built-in)")
68
+ p.add_argument(
69
+ "--classify-concurrency", type=int, default=4, help="parallel LLM requests (default 4)"
70
+ )
71
+
72
+
73
+ def _add_policy_options(p: argparse.ArgumentParser) -> None:
74
+ p.add_argument("--policy", type=Path, help="policy YAML (default: built-in default policy)")
75
+ p.add_argument("--baseline", type=Path, help="baseline JSON for rug-pull detection (CAP-006)")
76
+
77
+
78
+ def _add_report_options(p: argparse.ArgumentParser) -> None:
79
+ p.add_argument(
80
+ "--format",
81
+ choices=[f.value for f in Format],
82
+ default=Format.JSON.value,
83
+ help="output format for findings (default json)",
84
+ )
85
+ p.add_argument(
86
+ "--fail-on",
87
+ choices=[*(s.value for s in Severity), "none"],
88
+ default="none",
89
+ help="exit 1 if any finding is at or above this severity (default none)",
90
+ )
91
+ p.add_argument(
92
+ "--fail-on-not-assessed",
93
+ action="store_true",
94
+ help="exit 1 if any server or tool could not be assessed",
95
+ )
96
+
97
+
98
+ def _build_parser() -> argparse.ArgumentParser:
99
+ parser = argparse.ArgumentParser(
100
+ prog="agentcapmap",
101
+ description="Inventory MCP servers reachable by AI agents and map their capabilities.",
102
+ )
103
+ parser.add_argument(
104
+ "--version",
105
+ action="version",
106
+ version=f"agentcapmap {__version__} (installed at {Path(__file__).resolve().parent})",
107
+ )
108
+ sub = parser.add_subparsers(dest="command", required=True)
109
+
110
+ scan = sub.add_parser("scan", help="discover MCP configs and output a normalized inventory")
111
+ scan.add_argument(
112
+ "paths",
113
+ nargs="*",
114
+ type=Path,
115
+ help="config files or directories to scan (default: current directory, unless --user)",
116
+ )
117
+ scan.add_argument(
118
+ "--user", action="store_true", help="also scan per-user client configs on this machine"
119
+ )
120
+ scan.add_argument(
121
+ "--enumerate",
122
+ action="store_true",
123
+ help="connect to each server and list its tools (initialize + tools/list only)",
124
+ )
125
+ scan.add_argument(
126
+ "--timeout",
127
+ type=float,
128
+ default=DEFAULT_TIMEOUT,
129
+ help=f"per-server enumeration timeout in seconds (default {DEFAULT_TIMEOUT:.0f})",
130
+ )
131
+ scan.add_argument(
132
+ "--concurrency", type=int, default=4, help="servers to enumerate in parallel (default 4)"
133
+ )
134
+ scan.add_argument(
135
+ "--classify", action="store_true", help="classify enumerated tools (needs --enumerate)"
136
+ )
137
+ scan.add_argument(
138
+ "--evaluate",
139
+ action="store_true",
140
+ help="apply the policy and output findings instead of the inventory",
141
+ )
142
+ scan.add_argument(
143
+ "--save-inventory", type=Path, help="with --evaluate, also write the inventory here"
144
+ )
145
+ _add_classify_options(scan)
146
+ _add_policy_options(scan)
147
+ _add_report_options(scan)
148
+ _add_common(scan)
149
+
150
+ classify = sub.add_parser("classify", help="classify the tools in a saved inventory JSON")
151
+ classify.add_argument("inventory", type=Path, help="inventory JSON from `scan --enumerate`")
152
+ _add_classify_options(classify)
153
+ _add_common(classify)
154
+
155
+ ev = sub.add_parser("evaluate", help="apply a policy to a saved (classified) inventory")
156
+ ev.add_argument("inventory", type=Path, help="inventory JSON from `scan`")
157
+ _add_policy_options(ev)
158
+ _add_report_options(ev)
159
+ _add_common(ev)
160
+
161
+ report = sub.add_parser(
162
+ "report", help="render a saved evaluation (JSON from `evaluate`) in another format"
163
+ )
164
+ report.add_argument("evaluation", type=Path, help="evaluation JSON from `evaluate`")
165
+ _add_report_options(report)
166
+ _add_common(report)
167
+
168
+ diff = sub.add_parser(
169
+ "diff", help="compare two evaluations: new and resolved findings, gained capabilities"
170
+ )
171
+ diff.add_argument("old", type=Path, help="earlier evaluation JSON")
172
+ diff.add_argument("new", type=Path, help="later evaluation JSON")
173
+ diff.add_argument(
174
+ "--format", choices=["json", "markdown"], default="json", help="output format"
175
+ )
176
+ diff.add_argument(
177
+ "--fail-on",
178
+ choices=[*(s.value for s in Severity), "none"],
179
+ default="none",
180
+ help="exit 1 if a NEW finding is at or above this severity (default none)",
181
+ )
182
+ diff.add_argument(
183
+ "--fail-on-new-capability",
184
+ action="store_true",
185
+ help="exit 1 if any agent gained a capability it did not have before",
186
+ )
187
+ _add_common(diff)
188
+
189
+ baseline = sub.add_parser("baseline", help="manage tool-definition baselines")
190
+ baseline_sub = baseline.add_subparsers(dest="baseline_command", required=True)
191
+ create = baseline_sub.add_parser(
192
+ "create", help="pin the tool definition hashes of an enumerated inventory"
193
+ )
194
+ create.add_argument("inventory", type=Path, help="inventory JSON from `scan --enumerate`")
195
+ _add_common(create)
196
+ return parser
197
+
198
+
199
+ def _read_json_file(path: Path) -> str:
200
+ try:
201
+ if path.stat().st_size > _MAX_INVENTORY_BYTES:
202
+ raise CliError(f"{path} is larger than {_MAX_INVENTORY_BYTES} bytes")
203
+ return path.read_text(encoding="utf-8")
204
+ except OSError as exc:
205
+ raise CliError(f"cannot read {path}: {exc.strerror}") from None
206
+ except UnicodeDecodeError:
207
+ raise CliError(f"{path} is not UTF-8 text") from None
208
+
209
+
210
+ def _load_inventory(path: Path) -> Inventory:
211
+ try:
212
+ return Inventory.model_validate_json(_read_json_file(path))
213
+ except ValidationError as exc:
214
+ raise CliError(f"{path} is not a valid inventory: {exc.error_count()} error(s)") from None
215
+
216
+
217
+ def _load_evaluation(path: Path) -> Evaluation:
218
+ try:
219
+ return Evaluation.model_validate_json(_read_json_file(path))
220
+ except ValidationError as exc:
221
+ raise CliError(f"{path} is not a valid evaluation: {exc.error_count()} error(s)") from None
222
+
223
+
224
+ def _classify(inventory: Inventory, args: argparse.Namespace) -> None:
225
+ cache = ClassificationCache(args.cache_dir)
226
+ if args.offline:
227
+ classifier = Classifier(cache, offline=True)
228
+ else:
229
+ try:
230
+ provider = AnthropicProvider(model=args.model)
231
+ except Exception as exc: # SDK raises various errors when credentials are missing
232
+ # Fail closed: tools without a reviewed reference label or a cached answer
233
+ # stay unclassified and are reported as not assessed, never as safe.
234
+ print(
235
+ f"warning: no usable LLM client ({type(exc).__name__}); set ANTHROPIC_API_KEY "
236
+ "to classify new tools. Using reviewed reference labels and cached "
237
+ "classifications only; other tools will be reported as not assessed.",
238
+ file=sys.stderr,
239
+ )
240
+ classifier = Classifier(cache, offline=True)
241
+ else:
242
+ classifier = Classifier(cache, provider)
243
+ classify_inventory(inventory, classifier, concurrency=args.classify_concurrency)
244
+
245
+
246
+ def _evaluate(inventory: Inventory, args: argparse.Namespace) -> Evaluation:
247
+ try:
248
+ policy = load_policy(args.policy)
249
+ baseline: Baseline | None = load_baseline(args.baseline) if args.baseline else None
250
+ except (PolicyError, BaselineError) as exc:
251
+ raise CliError(str(exc)) from None
252
+ return evaluate(inventory, policy, baseline)
253
+
254
+
255
+ def _write(payload: str, output: Path | None) -> None:
256
+ if output:
257
+ output.write_text(payload, encoding="utf-8")
258
+ log.info("wrote %s", output)
259
+ else:
260
+ sys.stdout.write(payload)
261
+
262
+
263
+ def _emit(model: BaseModel, output: Path | None) -> None:
264
+ _write(model.model_dump_json(indent=2) + "\n", output)
265
+
266
+
267
+ def _report(evaluation: Evaluation, args: argparse.Namespace) -> int:
268
+ """Write the evaluation in the requested format; return the exit status."""
269
+ _write(render(evaluation, Format(args.format)), args.output)
270
+ fail_on = None if args.fail_on == "none" else Severity(args.fail_on)
271
+ if should_fail(evaluation, fail_on, args.fail_on_not_assessed):
272
+ at_or_above = sum(
273
+ 1 for f in evaluation.findings if fail_on and f.severity.rank >= fail_on.rank
274
+ )
275
+ log.warning(
276
+ "failing: %d finding(s) at or above --fail-on %s, %d not assessed",
277
+ at_or_above,
278
+ args.fail_on,
279
+ len(evaluation.not_assessed),
280
+ )
281
+ return EXIT_FINDINGS
282
+ return 0
283
+
284
+
285
+ def _print_scan_summary(inventory: Inventory, args: argparse.Namespace) -> None:
286
+ """One human-readable line on stderr, so an empty result is not a mystery."""
287
+ servers = sum(len(agent.servers) for agent in inventory.agents)
288
+ print(
289
+ f"scanned {len(inventory.agents)} agent config(s): {servers} MCP server(s) found, "
290
+ f"{len(inventory.errors)} error(s)",
291
+ file=sys.stderr,
292
+ )
293
+ if servers == 0:
294
+ places = [str(p) for p in user_config_paths()] if args.user else []
295
+ places += [str(a.config_path) for a in inventory.agents]
296
+ checked = "; ".join(dict.fromkeys(places)) or "no config files found"
297
+ print(redact_text(f"no MCP servers found. Looked at: {checked}"), file=sys.stderr)
298
+ elif not args.enumerate:
299
+ print(
300
+ "tools were not listed. Add --enumerate --classify --evaluate to assess what "
301
+ "each server can do (this starts the configured servers).",
302
+ file=sys.stderr,
303
+ )
304
+
305
+
306
+ def _cmd_scan(args: argparse.Namespace) -> int:
307
+ if args.classify and not args.enumerate:
308
+ raise CliError(
309
+ "--classify needs tool definitions: add --enumerate, "
310
+ "or run `agentcapmap classify` on a saved inventory"
311
+ )
312
+ if not args.evaluate and (
313
+ args.save_inventory
314
+ or args.format != Format.JSON.value
315
+ or args.fail_on != "none"
316
+ or args.fail_on_not_assessed
317
+ ):
318
+ raise CliError(
319
+ "--save-inventory, --format, --fail-on and --fail-on-not-assessed need --evaluate"
320
+ )
321
+ paths: list[Path] = args.paths or ([] if args.user else [Path.cwd()])
322
+ inventory = discover(paths, user=args.user, root=Path.cwd())
323
+ if args.enumerate:
324
+ print(ENUMERATE_WARNING, file=sys.stderr)
325
+ enumerate_inventory(inventory, timeout=args.timeout, concurrency=args.concurrency)
326
+ if args.classify:
327
+ _classify(inventory, args)
328
+ if not args.evaluate:
329
+ _print_scan_summary(inventory, args)
330
+ _emit(inventory, args.output)
331
+ return 0
332
+ if args.save_inventory:
333
+ _emit(inventory, args.save_inventory)
334
+ return _report(_evaluate(inventory, args), args)
335
+
336
+
337
+ def _cmd_classify(args: argparse.Namespace) -> int:
338
+ inventory = _load_inventory(args.inventory)
339
+ _classify(inventory, args)
340
+ _emit(inventory, args.output)
341
+ return 0
342
+
343
+
344
+ def _cmd_evaluate(args: argparse.Namespace) -> int:
345
+ return _report(_evaluate(_load_inventory(args.inventory), args), args)
346
+
347
+
348
+ def _cmd_report(args: argparse.Namespace) -> int:
349
+ return _report(_load_evaluation(args.evaluation), args)
350
+
351
+
352
+ def _cmd_diff(args: argparse.Namespace) -> int:
353
+ diff = diff_evaluations(_load_evaluation(args.old), _load_evaluation(args.new))
354
+ if args.format == "markdown":
355
+ _write(render_markdown(diff), args.output)
356
+ else:
357
+ _emit(diff, args.output)
358
+ fail_on = None if args.fail_on == "none" else Severity(args.fail_on)
359
+ if should_fail_diff(diff, fail_on, args.fail_on_new_capability):
360
+ log.warning(
361
+ "failing: %d new finding(s), %d gained capabilit(ies)",
362
+ len(diff.new_findings),
363
+ len(diff.gained_capabilities),
364
+ )
365
+ return EXIT_FINDINGS
366
+ return 0
367
+
368
+
369
+ def _cmd_baseline(args: argparse.Namespace) -> int:
370
+ baseline = create_baseline(_load_inventory(args.inventory))
371
+ if not baseline.servers:
372
+ raise CliError("no successfully enumerated servers to pin (run scan with --enumerate)")
373
+ _emit(baseline, args.output)
374
+ return 0
375
+
376
+
377
+ def main(argv: Sequence[str] | None = None) -> int:
378
+ args = _build_parser().parse_args(argv)
379
+ _setup_logging(getattr(args, "verbose", 0))
380
+ commands = {
381
+ "scan": _cmd_scan,
382
+ "classify": _cmd_classify,
383
+ "evaluate": _cmd_evaluate,
384
+ "report": _cmd_report,
385
+ "diff": _cmd_diff,
386
+ "baseline": _cmd_baseline,
387
+ }
388
+ try:
389
+ return commands[args.command](args)
390
+ except CliError as exc:
391
+ print(f"error: {exc}", file=sys.stderr)
392
+ return EXIT_ERROR
agentcapmap/diff.py ADDED
@@ -0,0 +1,103 @@
1
+ """Compare two evaluations: what findings and capabilities appeared or went away.
2
+
3
+ Findings are matched by their stable fingerprint; capabilities by agent id and the
4
+ `field=value` capability key. Both inputs are evaluation JSON produced by `evaluate`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from agentcapmap.findings import Evaluation, Finding, Severity
12
+ from agentcapmap.report._text import md_code
13
+
14
+ _MAX_ROWS = 100
15
+
16
+
17
+ class CapabilityChange(BaseModel):
18
+ agent_id: str
19
+ capability: str
20
+ tools: list[str] = Field(default_factory=list) # "server/tool"
21
+
22
+
23
+ class EvaluationDiff(BaseModel):
24
+ new_findings: list[Finding] = Field(default_factory=list)
25
+ resolved_findings: list[Finding] = Field(default_factory=list)
26
+ gained_capabilities: list[CapabilityChange] = Field(default_factory=list)
27
+ lost_capabilities: list[CapabilityChange] = Field(default_factory=list)
28
+
29
+
30
+ def _capabilities(evaluation: Evaluation) -> dict[tuple[str, str], list[str]]:
31
+ return {
32
+ (agent.agent_id, capability): sorted({f"{ref.server}/{ref.tool}" for ref in refs})
33
+ for agent in evaluation.agents
34
+ for capability, refs in agent.capabilities.items()
35
+ }
36
+
37
+
38
+ def _changes(
39
+ source: dict[tuple[str, str], list[str]], other: dict[tuple[str, str], list[str]]
40
+ ) -> list[CapabilityChange]:
41
+ return [
42
+ CapabilityChange(agent_id=agent, capability=capability, tools=tools)
43
+ for (agent, capability), tools in sorted(source.items())
44
+ if (agent, capability) not in other
45
+ ]
46
+
47
+
48
+ def diff_evaluations(old: Evaluation, new: Evaluation) -> EvaluationDiff:
49
+ old_prints = {f.fingerprint for f in old.findings}
50
+ new_prints = {f.fingerprint for f in new.findings}
51
+ old_caps, new_caps = _capabilities(old), _capabilities(new)
52
+ return EvaluationDiff(
53
+ new_findings=[f for f in new.findings if f.fingerprint not in old_prints],
54
+ resolved_findings=[f for f in old.findings if f.fingerprint not in new_prints],
55
+ gained_capabilities=_changes(new_caps, old_caps),
56
+ lost_capabilities=_changes(old_caps, new_caps),
57
+ )
58
+
59
+
60
+ def should_fail_diff(
61
+ diff: EvaluationDiff, fail_on: Severity | None, fail_on_new_capability: bool
62
+ ) -> bool:
63
+ """Fail on a *new* finding at or above `fail_on`, or on any gained capability."""
64
+ if fail_on is not None and any(f.severity.rank >= fail_on.rank for f in diff.new_findings):
65
+ return True
66
+ return fail_on_new_capability and bool(diff.gained_capabilities)
67
+
68
+
69
+ def _capability_lines(title: str, changes: list[CapabilityChange]) -> list[str]:
70
+ lines = [f"## {title} ({len(changes)})", ""]
71
+ if not changes:
72
+ return [*lines, "None.", ""]
73
+ for change in changes[:_MAX_ROWS]:
74
+ tools = ", ".join(md_code(t, 120) for t in change.tools[:10])
75
+ lines.append(
76
+ f"- {md_code(change.agent_id, 120)}: {md_code(change.capability, 80)} via {tools}"
77
+ )
78
+ if len(changes) > _MAX_ROWS:
79
+ lines.append(f"- ... and {len(changes) - _MAX_ROWS} more")
80
+ return [*lines, ""]
81
+
82
+
83
+ def _finding_lines(title: str, findings: list[Finding]) -> list[str]:
84
+ lines = [f"## {title} ({len(findings)})", ""]
85
+ if not findings:
86
+ return [*lines, "None.", ""]
87
+ for finding in findings[:_MAX_ROWS]:
88
+ lines.append(
89
+ f"- **{finding.severity.value}** {md_code(finding.rule_id, 40)}: "
90
+ f"{md_code(finding.message, 300)}"
91
+ )
92
+ if len(findings) > _MAX_ROWS:
93
+ lines.append(f"- ... and {len(findings) - _MAX_ROWS} more")
94
+ return [*lines, ""]
95
+
96
+
97
+ def render_markdown(diff: EvaluationDiff) -> str:
98
+ lines = ["# agent-capmap diff", ""]
99
+ lines += _finding_lines("New findings", diff.new_findings)
100
+ lines += _capability_lines("Gained capabilities", diff.gained_capabilities)
101
+ lines += _finding_lines("Resolved findings", diff.resolved_findings)
102
+ lines += _capability_lines("Lost capabilities", diff.lost_capabilities)
103
+ return "\n".join(lines)