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/redact.py ADDED
@@ -0,0 +1,268 @@
1
+ """Secret redaction for everything agent-capmap prints or logs.
2
+
3
+ Config files routinely embed credentials (env values, headers, CLI flags, connection
4
+ strings). Nothing derived from a config may reach output or logs without passing
5
+ through this module. Redaction is deliberately aggressive: a false positive costs a
6
+ little readability, a false negative leaks a credential.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ import threading
14
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
15
+
16
+ REDACTED = "<redacted>"
17
+
18
+ # Shortest value we register as a known secret. Shorter values ("1", "true") would
19
+ # scrub unrelated text without protecting anything meaningful.
20
+ _MIN_KNOWN_SECRET_LEN = 6
21
+
22
+ _TOKEN_PATTERNS: list[re.Pattern[str]] = [
23
+ re.compile(p)
24
+ for p in (
25
+ r"gh[pousr]_[A-Za-z0-9]{20,}",
26
+ r"github_pat_[A-Za-z0-9_]{20,}",
27
+ r"glpat-[A-Za-z0-9_\-]{20,}",
28
+ r"sk-ant-[A-Za-z0-9_\-]{10,}",
29
+ r"sk-[A-Za-z0-9_\-]{20,}",
30
+ r"xox[abposr]-[A-Za-z0-9\-]{10,}",
31
+ r"(?:AKIA|ASIA)[0-9A-Z]{16}",
32
+ r"AIza[0-9A-Za-z_\-]{35}",
33
+ r"npm_[A-Za-z0-9]{36}",
34
+ r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}", # JWT
35
+ r"-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----[\s\S]{0,16384}?"
36
+ r"-----END [A-Z ]{0,40}PRIVATE KEY-----",
37
+ )
38
+ ]
39
+ # Every quantifier below is bounded, and matches may only start at an identifier
40
+ # boundary, so hostile input (e.g. a malicious server's stderr) cannot trigger
41
+ # catastrophic backtracking.
42
+ _BEARER = re.compile(r"(?i)(?<![a-z])(bearer|basic|token)[ \t]{1,8}[A-Za-z0-9._~+/=\-]{8,4096}")
43
+ _URL_USERINFO = re.compile(
44
+ r"(?i)(?<![a-z0-9+.\-])([a-z][a-z0-9+.\-]{0,30}://[^/\s:@]{1,256}):[^@\s/]{1,256}@"
45
+ )
46
+ _ASSIGNMENT_IN_TEXT = re.compile(
47
+ r"(?<![A-Za-z0-9_.\-])([A-Za-z0-9_.\-]{1,128})([ \t]{0,8}[:=][ \t]{0,8})"
48
+ r"(\"[^\"\n]{0,1024}\"|'[^'\n]{0,1024}'|[^\s,;&\"']{1,1024})"
49
+ )
50
+ _SECRET_KEYWORD = re.compile(
51
+ r"(?i)token|secret|passw(?:or)?d|api[_\-]?key|apikey|authorization|credential|"
52
+ r"private[_\-]?key|access[_\-]?key"
53
+ )
54
+ # C0/C1 control characters (except tab/newline): could drive a terminal via escape codes.
55
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
56
+
57
+ _SENSITIVE_SUBSTRINGS = (
58
+ "token",
59
+ "secret",
60
+ "password",
61
+ "passwd",
62
+ "pwd",
63
+ "apikey",
64
+ "api_key",
65
+ "api-key",
66
+ "auth",
67
+ "credential",
68
+ "privatekey",
69
+ "private_key",
70
+ "private-key",
71
+ "accesskey",
72
+ "access_key",
73
+ "access-key",
74
+ "cookie",
75
+ "session",
76
+ "signature",
77
+ "dsn",
78
+ )
79
+ _SENSITIVE_WORDS = re.compile(r"(?i)(?:^|[_\-.])(?:pat|key|pass|sig|jwt)(?:$|[_\-.])")
80
+ _VAR_REFERENCE = re.compile(r"^\$\{[^}]+\}$|^\$[A-Za-z_][A-Za-z0-9_]*$|^%[A-Za-z_][A-Za-z0-9_]*%$")
81
+ _ANY_VAR_REFERENCE = re.compile(r"\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%")
82
+
83
+
84
+ class _KnownSecrets:
85
+ """Raw secret values seen in configs, scrubbed verbatim from any text."""
86
+
87
+ def __init__(self) -> None:
88
+ self._values: set[str] = set()
89
+ self._lock = threading.Lock()
90
+
91
+ def add(self, value: str) -> None:
92
+ if len(value) >= _MIN_KNOWN_SECRET_LEN and not is_variable_reference(value):
93
+ with self._lock:
94
+ self._values.add(value)
95
+
96
+ def scrub(self, text: str) -> str:
97
+ with self._lock:
98
+ values = sorted(self._values, key=len, reverse=True)
99
+ for value in values:
100
+ if value in text:
101
+ text = text.replace(value, REDACTED)
102
+ return text
103
+
104
+ def clear(self) -> None:
105
+ with self._lock:
106
+ self._values.clear()
107
+
108
+
109
+ known_secrets = _KnownSecrets()
110
+
111
+
112
+ def is_sensitive_name(name: str) -> bool:
113
+ """Whether a key, header, flag or parameter name likely holds a credential."""
114
+ lowered = name.strip().lstrip("-").lower()
115
+ if not lowered:
116
+ return False
117
+ return any(s in lowered for s in _SENSITIVE_SUBSTRINGS) or bool(
118
+ _SENSITIVE_WORDS.search(lowered)
119
+ )
120
+
121
+
122
+ def is_variable_reference(value: str) -> bool:
123
+ """`${VAR}`, `${env:VAR}`, `${input:id}`, `$VAR` or `%VAR%`: a pointer, not a secret."""
124
+ return bool(_VAR_REFERENCE.match(value.strip()))
125
+
126
+
127
+ def looks_like_secret(value: str) -> bool:
128
+ """High-entropy-looking opaque string (long, mixes letters and digits)."""
129
+ if len(value) < 24 or any(c in value for c in " ./\\"):
130
+ return False
131
+ return any(c.isdigit() for c in value) and any(c.isalpha() for c in value)
132
+
133
+
134
+ def _redact_assignment(m: re.Match[str]) -> str:
135
+ key, sep, _value = m.groups()
136
+ if _SECRET_KEYWORD.search(key):
137
+ return f"{key}{sep}{REDACTED}"
138
+ return m.group(0)
139
+
140
+
141
+ def redact_text(text: str) -> str:
142
+ """Scrub known secrets and secret-shaped substrings from free text."""
143
+ if not text:
144
+ return text
145
+ text = known_secrets.scrub(text)
146
+ for pattern in _TOKEN_PATTERNS:
147
+ text = pattern.sub(REDACTED, text)
148
+ text = _BEARER.sub(lambda m: f"{m.group(1)} {REDACTED}", text)
149
+ text = _URL_USERINFO.sub(lambda m: f"{m.group(1)}:{REDACTED}@", text)
150
+ text = _ASSIGNMENT_IN_TEXT.sub(_redact_assignment, text)
151
+ return text
152
+
153
+
154
+ def escape_control_chars(text: str) -> str:
155
+ """Make control characters visible (`\\x1b`) so untrusted text cannot drive a terminal."""
156
+ return _CONTROL_CHARS.sub(lambda m: f"\\x{ord(m.group(0)):02x}", text)
157
+
158
+
159
+ def redact_value(value: str) -> str:
160
+ """Redact a value whose key is already known (env var, header): keep only references.
161
+
162
+ `Bearer ${input:pat}` is kept too: an auth scheme plus a reference holds no secret.
163
+ """
164
+ remainder = _ANY_VAR_REFERENCE.sub("", value).strip().lower()
165
+ if remainder in ("", "bearer", "basic", "token"):
166
+ return value
167
+ return REDACTED
168
+
169
+
170
+ def redact_url(url: str) -> str:
171
+ """Redact userinfo passwords, sensitive query params and opaque path segments."""
172
+ try:
173
+ parts = urlsplit(url)
174
+ except ValueError:
175
+ return redact_text(url)
176
+ if not parts.scheme or not parts.netloc:
177
+ return redact_text(url)
178
+
179
+ netloc = parts.netloc
180
+ if "@" in netloc:
181
+ userinfo, host = netloc.rsplit("@", 1)
182
+ user = userinfo.split(":", 1)[0]
183
+ netloc = f"{user}:{REDACTED}@{host}" if ":" in userinfo else f"{REDACTED}@{host}"
184
+
185
+ path = "/".join(REDACTED if looks_like_secret(seg) else seg for seg in parts.path.split("/"))
186
+ query_pairs = parse_qsl(parts.query, keep_blank_values=True)
187
+ query = urlencode(
188
+ [
189
+ (k, REDACTED if is_sensitive_name(k) or looks_like_secret(v) else v)
190
+ for k, v in query_pairs
191
+ ],
192
+ safe="<>",
193
+ )
194
+ return redact_text(urlunsplit((parts.scheme, netloc, path, query, parts.fragment)))
195
+
196
+
197
+ _FLAG_WITH_VALUE = re.compile(r"^(--?[A-Za-z0-9_.\-]+)=(.*)$", re.DOTALL)
198
+ _ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.DOTALL)
199
+
200
+
201
+ def redact_args(args: list[str]) -> list[str]:
202
+ """Redact a command line: values of sensitive flags, KEY=VALUE pairs, URLs, tokens."""
203
+ out: list[str] = []
204
+ redact_next = False
205
+ for arg in args:
206
+ if redact_next:
207
+ out.append(redact_value(arg))
208
+ redact_next = False
209
+ continue
210
+ if m := _FLAG_WITH_VALUE.match(arg):
211
+ flag, value = m.groups()
212
+ if is_sensitive_name(flag):
213
+ out.append(f"{flag}={redact_value(value)}")
214
+ else:
215
+ out.append(f"{flag}={_redact_free_value(value)}")
216
+ continue
217
+ if arg.startswith("-") and is_sensitive_name(arg):
218
+ out.append(arg)
219
+ redact_next = True
220
+ continue
221
+ if (m := _ASSIGNMENT.match(arg)) and is_sensitive_name(m.group(1)):
222
+ out.append(f"{m.group(1)}={redact_value(m.group(2))}")
223
+ continue
224
+ out.append(_redact_free_value(arg))
225
+ return out
226
+
227
+
228
+ def sensitive_arg_names(args: list[str]) -> list[str]:
229
+ """Names of credential-carrying args, for auth evidence (never values)."""
230
+ names: list[str] = []
231
+ for i, arg in enumerate(args):
232
+ if m := _FLAG_WITH_VALUE.match(arg):
233
+ if is_sensitive_name(m.group(1)):
234
+ names.append(m.group(1))
235
+ elif arg.startswith("-") and is_sensitive_name(arg) and i + 1 < len(args):
236
+ names.append(arg)
237
+ elif (m := _ASSIGNMENT.match(arg)) and is_sensitive_name(m.group(1)):
238
+ names.append(m.group(1))
239
+ elif redact_text(arg) != arg or ("://" in arg and redact_url(arg) != arg):
240
+ names.append(f"arg[{i}]")
241
+ return names
242
+
243
+
244
+ def _redact_free_value(value: str) -> str:
245
+ if "://" in value:
246
+ return redact_url(value)
247
+ return redact_text(value)
248
+
249
+
250
+ class RedactingFilter(logging.Filter):
251
+ """Logging filter that scrubs the fully formatted message before any handler sees it."""
252
+
253
+ def filter(self, record: logging.LogRecord) -> bool:
254
+ record.msg = escape_control_chars(redact_text(record.getMessage()))
255
+ record.args = None
256
+ if record.exc_info and record.exc_info[1] is not None:
257
+ formatted = logging.Formatter().formatException(record.exc_info)
258
+ record.exc_text = escape_control_chars(redact_text(formatted))
259
+ record.exc_info = None
260
+ return True
261
+
262
+
263
+ def get_logger(name: str) -> logging.Logger:
264
+ """Logger with redaction attached at the logger level, so every handler is covered."""
265
+ logger = logging.getLogger(name)
266
+ if not any(isinstance(f, RedactingFilter) for f in logger.filters):
267
+ logger.addFilter(RedactingFilter())
268
+ return logger
@@ -0,0 +1,36 @@
1
+ """Render an evaluation as JSON, Markdown or SARIF, and decide the exit status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from enum import StrEnum
7
+
8
+ from agentcapmap.findings import Evaluation, Severity
9
+ from agentcapmap.report import markdown, sarif
10
+
11
+
12
+ class Format(StrEnum):
13
+ JSON = "json"
14
+ MARKDOWN = "markdown"
15
+ SARIF = "sarif"
16
+
17
+
18
+ def render(evaluation: Evaluation, fmt: Format) -> str:
19
+ if fmt is Format.MARKDOWN:
20
+ return markdown.render(evaluation)
21
+ if fmt is Format.SARIF:
22
+ return json.dumps(sarif.render(evaluation), indent=2, ensure_ascii=False) + "\n"
23
+ return evaluation.model_dump_json(indent=2) + "\n"
24
+
25
+
26
+ def should_fail(
27
+ evaluation: Evaluation, fail_on: Severity | None, fail_on_not_assessed: bool = False
28
+ ) -> bool:
29
+ """True when a finding is at or above `fail_on`, or anything was not assessed and
30
+ `fail_on_not_assessed` is set."""
31
+ if fail_on is not None and any(f.severity.rank >= fail_on.rank for f in evaluation.findings):
32
+ return True
33
+ return fail_on_not_assessed and bool(evaluation.not_assessed)
34
+
35
+
36
+ __all__ = ["Format", "render", "should_fail"]
@@ -0,0 +1,62 @@
1
+ """Helpers for putting untrusted text (tool names, descriptions, evidence, policy
2
+ messages) into reports.
3
+
4
+ Everything that came from an MCP server, a config file or a policy file is treated as
5
+ hostile: redacted again (defense in depth), stripped of control characters, collapsed
6
+ to one line, and truncated. In Markdown it is rendered only inside code spans, where
7
+ links, HTML and emphasis are inert.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from agentcapmap.redact import redact_text
15
+
16
+ # C0/C1 controls, line/paragraph separators, zero-width and bidi-override characters
17
+ # ("Trojan Source"). Built from code points so this source file stays ASCII.
18
+ _INVISIBLE_RANGES = (
19
+ (0x00, 0x1F),
20
+ (0x7F, 0x9F),
21
+ (0x200B, 0x200F),
22
+ (0x2028, 0x2029),
23
+ (0x202A, 0x202E),
24
+ (0x2066, 0x2069),
25
+ )
26
+ _CONTROL = re.compile(
27
+ "["
28
+ + "".join(f"{re.escape(chr(lo))}-{re.escape(chr(hi))}" for lo, hi in _INVISIBLE_RANGES)
29
+ + "]"
30
+ )
31
+ _SPACE = re.compile(r"\s+")
32
+ _BACKTICKS = re.compile(r"`+")
33
+ ELLIPSIS = chr(0x2026) # horizontal ellipsis
34
+
35
+
36
+ def clean(text: object, limit: int = 300) -> str:
37
+ """One line of plain text: redacted, no control or bidi characters, bounded length."""
38
+ value = redact_text(str(text))
39
+ value = _CONTROL.sub(" ", value)
40
+ value = _SPACE.sub(" ", value).strip()
41
+ if len(value) > limit:
42
+ value = value[: limit - 1].rstrip() + ELLIPSIS
43
+ return value
44
+
45
+
46
+ def md_code(text: object, limit: int = 300, *, in_table: bool = False) -> str:
47
+ """Render untrusted text as an inline code span.
48
+
49
+ The fence is one backtick longer than the longest backtick run inside, so the text
50
+ cannot close the span early. In tables `|` is escaped, which GFM honours even inside
51
+ code spans.
52
+ """
53
+ value = clean(text, limit)
54
+ if not value:
55
+ return "-"
56
+ longest = max((len(m.group(0)) for m in _BACKTICKS.finditer(value)), default=0)
57
+ fence = "`" * (longest + 1)
58
+ if value.startswith("`") or value.endswith("`"):
59
+ value = f" {value} "
60
+ if in_table:
61
+ value = value.replace("|", "\\|")
62
+ return f"{fence}{value}{fence}"
@@ -0,0 +1,152 @@
1
+ """Markdown report (also used for the GitHub Actions job summary).
2
+
3
+ Only constant labels, numbers and severities appear outside code spans; every value
4
+ derived from configs, servers or policy files goes through `md_code`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agentcapmap.findings import Evaluation, Finding, Severity
10
+ from agentcapmap.report._text import md_code
11
+
12
+ _SEVERITY_LABEL = {
13
+ Severity.CRITICAL: "Critical",
14
+ Severity.HIGH: "High",
15
+ Severity.MEDIUM: "Medium",
16
+ Severity.LOW: "Low",
17
+ Severity.INFO: "Info",
18
+ }
19
+ _MAX_FINDINGS = 200
20
+ _MAX_ROWS = 50
21
+
22
+
23
+ def _finding(index: int, finding: Finding) -> list[str]:
24
+ where = finding.location
25
+ location = f"{where.path}:{where.line}" if where and where.line else finding.config_path
26
+ lines = [
27
+ f"### {index}. {md_code(finding.rule_id, 40)} | {_SEVERITY_LABEL[finding.severity]}",
28
+ "",
29
+ md_code(finding.message, 500),
30
+ "",
31
+ f"- **Agent:** {md_code(finding.agent_id, 200)} at {md_code(location, 200)}",
32
+ f"- **Servers:** {', '.join(md_code(s, 100) for s in finding.servers)}",
33
+ ]
34
+ if finding.tools:
35
+ lines.append(
36
+ f"- **Tools:** {', '.join(md_code(t, 120) for t in finding.tools[:_MAX_ROWS])}"
37
+ )
38
+ if finding.maps_to:
39
+ lines.append(f"- **Maps to:** {', '.join(md_code(m, 64) for m in finding.maps_to)}")
40
+ if finding.details:
41
+ for key in ("changed", "added", "removed"):
42
+ names = finding.details.get(key)
43
+ if names:
44
+ rendered = ", ".join(md_code(n, 120) for n in names[:_MAX_ROWS])
45
+ lines.append(f"- **Tools {key}:** {rendered}")
46
+ if finding.evidence:
47
+ lines += ["", "| Server | Tool | Matched | Evidence |", "|---|---|---|---|"]
48
+ for ev in finding.evidence[:_MAX_ROWS]:
49
+ matched = ", ".join(f"{k}={str(v).lower()}" for k, v in ev.matched.items())
50
+ phrases = "; ".join(f"{k}: {v}" for k, v in ev.phrases.items())
51
+ if ev.unverified:
52
+ phrases += f" (unverified: {', '.join(ev.unverified)})"
53
+ lines.append(
54
+ "| "
55
+ + " | ".join(
56
+ [
57
+ md_code(ev.server, 100, in_table=True),
58
+ md_code(ev.tool or "", 100, in_table=True),
59
+ md_code(matched, 200, in_table=True),
60
+ md_code(phrases, 300, in_table=True),
61
+ ]
62
+ )
63
+ + " |"
64
+ )
65
+ if len(finding.evidence) > _MAX_ROWS:
66
+ lines.append(f"\n_{len(finding.evidence) - _MAX_ROWS} more evidence rows omitted._")
67
+ return [*lines, ""]
68
+
69
+
70
+ def render(evaluation: Evaluation) -> str:
71
+ summary = evaluation.summary
72
+ lines = [
73
+ "# agent-capmap report",
74
+ "",
75
+ f"**Policy:** {md_code(evaluation.policy.source, 200)} "
76
+ f"(sha256 {md_code(evaluation.policy.sha256[:12], 12)}) | "
77
+ f"**Baseline:** {'used' if evaluation.baseline_used else 'not used'} | "
78
+ f"**Agents:** {len(evaluation.agents)}",
79
+ "",
80
+ "| Severity | Findings |",
81
+ "|---|---|",
82
+ *(f"| {_SEVERITY_LABEL[s]} | {summary.get(s.value, 0)} |" for s in Severity),
83
+ "",
84
+ ]
85
+ not_assessed = len(evaluation.not_assessed)
86
+ if not_assessed:
87
+ lines += [
88
+ f"> [!WARNING]\n> {not_assessed} server(s) or tool(s) were **not assessed**, so "
89
+ "findings may be incomplete. See the table at the end.",
90
+ "",
91
+ ]
92
+ if evaluation.skipped_rules:
93
+ skipped = ", ".join(
94
+ f"{md_code(s.rule_id, 40)} ({md_code(s.reason, 100)})" for s in evaluation.skipped_rules
95
+ )
96
+ lines += [f"Skipped rules: {skipped}", ""]
97
+
98
+ lines += ["## Findings", ""]
99
+ if not evaluation.findings and not_assessed:
100
+ lines += [
101
+ "No findings, but this is **not an all-clear**: some tools were not assessed. "
102
+ "To assess them, run `agentcapmap scan <path> --enumerate --classify --evaluate` "
103
+ "(this starts the configured servers and needs an API key), or use a saved "
104
+ "inventory whose tools are already classified.",
105
+ "",
106
+ ]
107
+ elif not evaluation.findings:
108
+ lines += ["No findings.", ""]
109
+ for index, finding in enumerate(evaluation.findings[:_MAX_FINDINGS], start=1):
110
+ lines += _finding(index, finding)
111
+ if len(evaluation.findings) > _MAX_FINDINGS:
112
+ lines += [
113
+ f"_{len(evaluation.findings) - _MAX_FINDINGS} more findings omitted; "
114
+ "see the JSON output._",
115
+ "",
116
+ ]
117
+
118
+ if not_assessed:
119
+ lines += ["## Not assessed", "", "| Agent | Server | Tool | Reason |", "|---|---|---|---|"]
120
+ for item in evaluation.not_assessed[:_MAX_FINDINGS]:
121
+ lines.append(
122
+ "| "
123
+ + " | ".join(
124
+ [
125
+ md_code(item.agent_id, 120, in_table=True),
126
+ md_code(item.server, 100, in_table=True),
127
+ md_code(item.tool or "", 100, in_table=True),
128
+ md_code(item.reason, 200, in_table=True),
129
+ ]
130
+ )
131
+ + " |"
132
+ )
133
+ lines.append("")
134
+
135
+ lines += ["## Capabilities by agent", ""]
136
+ for agent in evaluation.agents:
137
+ lines += [
138
+ f"### {md_code(agent.agent_id, 200)}",
139
+ "",
140
+ f"{agent.tools_assessed} of {agent.tools_total} tool(s) assessed.",
141
+ "",
142
+ ]
143
+ if not agent.capabilities:
144
+ lines += ["No capabilities beyond the defaults.", ""]
145
+ continue
146
+ lines += ["| Capability | Tools |", "|---|---|"]
147
+ for capability, refs in agent.capabilities.items():
148
+ tools = ", ".join(f"{r.server}/{r.tool}" for r in refs[:_MAX_ROWS])
149
+ cap_cell = md_code(capability, 60, in_table=True)
150
+ lines.append(f"| {cap_cell} | {md_code(tools, 500, in_table=True)} |")
151
+ lines.append("")
152
+ return "\n".join(lines).rstrip() + "\n"