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
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)
|