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,209 @@
1
+ """Find MCP client configs and normalize them into `Agent -> [Server]`.
2
+
3
+ Each config file is one agent: the set of servers one client instance can reach.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from collections.abc import Iterable, Iterator
11
+ from pathlib import Path
12
+
13
+ from agentcapmap.discovery import claude_code, claude_desktop, cursor, mcp_json, vscode
14
+ from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc
15
+ from agentcapmap.limits import MAX_CONFIG_BYTES
16
+ from agentcapmap.models import Agent, DiscoveryError, Inventory
17
+ from agentcapmap.redact import get_logger
18
+
19
+ log = get_logger(__name__)
20
+
21
+ # Order matters: the first format whose `matches` accepts a path wins.
22
+ FORMATS: list[ConfigFormat] = [
23
+ *claude_code.FORMATS,
24
+ *claude_desktop.FORMATS,
25
+ *cursor.FORMATS,
26
+ *vscode.FORMATS,
27
+ *mcp_json.FORMATS,
28
+ ]
29
+ _BY_NAME = {f.name: f for f in FORMATS}
30
+
31
+ CANDIDATE_NAMES = frozenset(
32
+ {"claude_desktop_config.json", "mcp.json", ".mcp.json", "settings.json"}
33
+ )
34
+ SKIP_DIRS = frozenset(
35
+ {
36
+ ".git",
37
+ ".hg",
38
+ ".svn",
39
+ "node_modules",
40
+ ".venv",
41
+ "venv",
42
+ "__pycache__",
43
+ ".mypy_cache",
44
+ ".pytest_cache",
45
+ ".ruff_cache",
46
+ ".tox",
47
+ "dist",
48
+ "build",
49
+ "site-packages",
50
+ }
51
+ )
52
+
53
+ __all__ = ["FORMATS", "ConfigError", "detect_format", "discover", "user_config_paths"]
54
+
55
+
56
+ def detect_format(path: Path, text: str | None = None) -> ConfigFormat | None:
57
+ """Pick a format from the file location, falling back to sniffing the content."""
58
+ for fmt in FORMATS:
59
+ if fmt.matches(path):
60
+ return fmt
61
+ if text is None:
62
+ return None
63
+ try:
64
+ data = load_jsonc(text)
65
+ except ConfigError:
66
+ return None
67
+ if not isinstance(data, dict):
68
+ return None
69
+ if "mcpServers" in data:
70
+ return _BY_NAME["mcp_json"]
71
+ if isinstance(data.get("servers"), dict):
72
+ return _BY_NAME["vscode"]
73
+ mcp = data.get("mcp")
74
+ if isinstance(mcp, dict) and isinstance(mcp.get("servers"), dict):
75
+ return _BY_NAME["vscode_settings"]
76
+ return None
77
+
78
+
79
+ def _display_path(path: Path, root: Path | None) -> str:
80
+ if root is not None:
81
+ try:
82
+ return path.resolve().relative_to(root.resolve()).as_posix()
83
+ except ValueError:
84
+ pass
85
+ return path.as_posix()
86
+
87
+
88
+ def parse_config_agents(
89
+ path: Path, *, root: Path | None = None, explicit: bool = False
90
+ ) -> tuple[list[Agent], DiscoveryError | None]:
91
+ """Parse one config file into its agents (most formats describe exactly one).
92
+
93
+ Discovered-by-walk files that turn out not to be MCP configs return ([], None);
94
+ `explicit` files the user named directly report an error instead.
95
+ """
96
+ display = _display_path(path, root)
97
+ by_name = detect_format(path)
98
+ limit = (by_name.max_bytes if by_name else None) or MAX_CONFIG_BYTES
99
+ try:
100
+ if path.stat().st_size > limit:
101
+ return [], DiscoveryError(
102
+ path=display, message=f"file larger than {limit} bytes; skipped"
103
+ )
104
+ text = path.read_text(encoding="utf-8-sig")
105
+ except OSError as exc:
106
+ return [], DiscoveryError(path=display, message=f"cannot read file: {exc.strerror}")
107
+ except UnicodeDecodeError:
108
+ return [], DiscoveryError(path=display, message="cannot read file: not UTF-8 text")
109
+
110
+ fmt = by_name or detect_format(path, text)
111
+ if fmt is None:
112
+ if explicit:
113
+ return [], DiscoveryError(path=display, message="not a recognized MCP config")
114
+ return [], None
115
+ try:
116
+ if fmt.parse_agents is not None:
117
+ groups = fmt.parse_agents(text, path, display)
118
+ else:
119
+ groups = [("", fmt.parse(text, path, display))]
120
+ except ConfigError as exc:
121
+ return [], DiscoveryError(path=display, message=str(exc))
122
+
123
+ agents = []
124
+ for suffix, servers in groups:
125
+ if not servers and not explicit and fmt.parse_agents is None:
126
+ continue # e.g. an unrelated settings.json
127
+ agent_id = f"{fmt.client}:{display}" + (f"#{suffix}" if suffix else "")
128
+ agents.append(Agent(id=agent_id, client=fmt.client, config_path=display, servers=servers))
129
+ log.debug("parsed %s as %s: %d agent(s)", display, fmt.name, len(agents))
130
+ return agents, None
131
+
132
+
133
+ def parse_config_file(
134
+ path: Path, *, root: Path | None = None, explicit: bool = False
135
+ ) -> tuple[Agent | None, DiscoveryError | None]:
136
+ """Single-agent convenience wrapper around `parse_config_agents`."""
137
+ agents, error = parse_config_agents(path, root=root, explicit=explicit)
138
+ return (agents[0] if agents else None), error
139
+
140
+
141
+ def iter_candidate_files(root: Path) -> Iterator[Path]:
142
+ """Walk a directory tree yielding files whose names match a known config format."""
143
+ for dirpath, dirnames, filenames in os.walk(root):
144
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
145
+ for name in sorted(filenames):
146
+ if name.lower() in CANDIDATE_NAMES:
147
+ path = Path(dirpath) / name
148
+ if detect_format(path) is not None:
149
+ yield path
150
+
151
+
152
+ def user_config_paths() -> list[Path]:
153
+ """Known per-user config locations for the supported clients on this OS."""
154
+ home = Path.home()
155
+ if sys.platform == "win32":
156
+ appdata = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming"))
157
+ claude_dir, code_user = appdata / "Claude", appdata / "Code" / "User"
158
+ elif sys.platform == "darwin":
159
+ support = home / "Library" / "Application Support"
160
+ claude_dir, code_user = support / "Claude", support / "Code" / "User"
161
+ else:
162
+ config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
163
+ claude_dir, code_user = config / "Claude", config / "Code" / "User"
164
+ claude_code_home = Path(os.environ.get("CLAUDE_CONFIG_DIR") or home)
165
+ return [
166
+ claude_code_home / ".claude.json",
167
+ claude_dir / "claude_desktop_config.json",
168
+ home / ".cursor" / "mcp.json",
169
+ code_user / "mcp.json",
170
+ code_user / "settings.json",
171
+ ]
172
+
173
+
174
+ def discover(paths: Iterable[Path], *, user: bool = False, root: Path | None = None) -> Inventory:
175
+ """Build an inventory from files and/or directories, plus user-level configs if asked.
176
+
177
+ Config paths in the output are relative to `root` when given (the CLI passes the
178
+ working directory, so SARIF paths are repository-relative), otherwise to each
179
+ scanned directory.
180
+ """
181
+ inventory = Inventory()
182
+ seen: set[Path] = set()
183
+
184
+ def add(path: Path, root: Path | None, explicit: bool) -> None:
185
+ resolved = path.resolve()
186
+ if resolved in seen:
187
+ return
188
+ seen.add(resolved)
189
+ agents, error = parse_config_agents(path, root=root, explicit=explicit)
190
+ inventory.agents.extend(agents)
191
+ if error is not None:
192
+ log.warning("%s: %s", error.path, error.message)
193
+ inventory.errors.append(error)
194
+
195
+ for target in paths:
196
+ if target.is_dir():
197
+ for candidate in iter_candidate_files(target):
198
+ add(candidate, root or target, explicit=False)
199
+ elif target.is_file():
200
+ add(target, root or Path.cwd(), explicit=True)
201
+ else:
202
+ inventory.errors.append(DiscoveryError(path=str(target), message="path does not exist"))
203
+
204
+ if user:
205
+ for candidate in user_config_paths():
206
+ if candidate.is_file():
207
+ add(candidate, None, explicit=False)
208
+
209
+ return inventory
@@ -0,0 +1,297 @@
1
+ """Helpers shared by the per-format config parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from agentcapmap.models import (
13
+ ApprovalConfig,
14
+ AuthHint,
15
+ AuthMethod,
16
+ LaunchSpec,
17
+ Server,
18
+ SourceLocation,
19
+ Transport,
20
+ )
21
+ from agentcapmap.redact import (
22
+ is_sensitive_name,
23
+ known_secrets,
24
+ redact_args,
25
+ redact_text,
26
+ redact_url,
27
+ redact_value,
28
+ sensitive_arg_names,
29
+ )
30
+
31
+
32
+ class ConfigError(ValueError):
33
+ """A config file exists but cannot be parsed into servers."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ConfigFormat:
38
+ """One client config format. Parsers are isolated per format so they can evolve alone."""
39
+
40
+ name: str
41
+ client: str
42
+ matches: Callable[[Path], bool]
43
+ # (file text, real path, path to show in output) -> servers
44
+ parse: Callable[[str, Path, str], list[Server]]
45
+ # Formats describing several agents in one file return (id suffix, servers) pairs.
46
+ parse_agents: Callable[[str, Path, str], list[tuple[str, list[Server]]]] | None = None
47
+ # Size cap for this format; None means the default MAX_CONFIG_BYTES.
48
+ max_bytes: int | None = None
49
+
50
+
51
+ def load_jsonc(text: str) -> Any:
52
+ """Parse JSON that may contain // and /* */ comments and trailing commas (VS Code style)."""
53
+ out: list[str] = []
54
+ i, n = 0, len(text)
55
+ in_string = False
56
+ while i < n:
57
+ c = text[i]
58
+ if in_string:
59
+ out.append(c)
60
+ if c == "\\" and i + 1 < n:
61
+ out.append(text[i + 1])
62
+ i += 2
63
+ continue
64
+ if c == '"':
65
+ in_string = False
66
+ i += 1
67
+ continue
68
+ if c == '"':
69
+ in_string = True
70
+ out.append(c)
71
+ i += 1
72
+ elif text.startswith("//", i):
73
+ while i < n and text[i] not in "\r\n":
74
+ i += 1
75
+ elif text.startswith("/*", i):
76
+ end = text.find("*/", i + 2)
77
+ if end == -1:
78
+ raise ConfigError("unterminated block comment")
79
+ out.append("\n" * text.count("\n", i, end)) # keep line numbers stable
80
+ i = end + 2
81
+ elif c == ",":
82
+ j = i + 1
83
+ while j < n and text[j] in " \t\r\n":
84
+ j += 1
85
+ if j < n and text[j] in "]}":
86
+ i += 1 # drop trailing comma
87
+ else:
88
+ out.append(c)
89
+ i += 1
90
+ else:
91
+ out.append(c)
92
+ i += 1
93
+ try:
94
+ return json.loads("".join(out))
95
+ except json.JSONDecodeError as exc:
96
+ raise ConfigError(f"invalid JSON: {exc.msg} at line {exc.lineno}") from None
97
+ except RecursionError:
98
+ raise ConfigError("invalid JSON: nested too deeply") from None
99
+
100
+
101
+ def find_key_line(text: str, key: str, start: int = 0) -> int | None:
102
+ """1-based line of the first `"key":` at or after offset `start`."""
103
+ m = re.compile(r'"' + re.escape(json.dumps(key)[1:-1]) + r'"\s*:').search(text, start)
104
+ if m is None:
105
+ return None
106
+ return text.count("\n", 0, m.start()) + 1
107
+
108
+
109
+ def section_offset(text: str, key: str, after: int = 0) -> int:
110
+ m = re.compile(r'"' + re.escape(key) + r'"\s*:').search(text, after)
111
+ return m.end() if m else after
112
+
113
+
114
+ _TRANSPORT_ALIASES = {
115
+ "stdio": Transport.STDIO,
116
+ "http": Transport.HTTP,
117
+ "streamable-http": Transport.HTTP,
118
+ "streamablehttp": Transport.HTTP,
119
+ "streamable_http": Transport.HTTP,
120
+ "sse": Transport.SSE,
121
+ "ws": Transport.WS,
122
+ "websocket": Transport.WS,
123
+ }
124
+
125
+
126
+ def infer_transport(entry: dict[str, Any], url: str | None) -> Transport:
127
+ declared = entry.get("type") or entry.get("transport")
128
+ if declared is not None and not isinstance(declared, str):
129
+ raise ConfigError(f"transport type must be a string, got {declared!r}")
130
+ if declared:
131
+ transport = _TRANSPORT_ALIASES.get(declared.strip().lower())
132
+ if transport is None:
133
+ raise ConfigError(f"unknown transport type {declared!r}")
134
+ return transport
135
+ if url:
136
+ path = url.split("?", 1)[0].rstrip("/")
137
+ return Transport.SSE if path.endswith("/sse") else Transport.HTTP
138
+ return Transport.STDIO
139
+
140
+
141
+ def _str_dict(value: Any, field: str) -> dict[str, str]:
142
+ if value is None:
143
+ return {}
144
+ if not isinstance(value, dict):
145
+ raise ConfigError(f"{field!r} must be an object")
146
+ return {str(k): "" if v is None else str(v) for k, v in value.items()}
147
+
148
+
149
+ def _str_list(value: Any, field: str) -> list[str]:
150
+ if value is None:
151
+ return []
152
+ if not isinstance(value, list):
153
+ raise ConfigError(f"{field!r} must be an array")
154
+ return [str(v) for v in value]
155
+
156
+
157
+ def infer_auth(
158
+ transport: Transport,
159
+ env: dict[str, str],
160
+ headers: dict[str, str],
161
+ args: list[str],
162
+ url: str | None,
163
+ env_file: str | None,
164
+ ) -> AuthHint:
165
+ """Best-effort auth classification from config alone. Names only, never values."""
166
+ evidence: list[str] = []
167
+ evidence += [f"header:{k}" for k in headers if is_sensitive_name(k)]
168
+ evidence += [f"env:{k}" for k in env if is_sensitive_name(k)]
169
+ evidence += [f"arg:{name}" for name in sensitive_arg_names(args)]
170
+ if url and redact_url(url) != url:
171
+ evidence.append("url:embedded-credential")
172
+ if evidence:
173
+ return AuthHint(method=AuthMethod.STATIC, evidence=evidence)
174
+ if env_file:
175
+ return AuthHint(method=AuthMethod.UNKNOWN, evidence=["envFile"])
176
+ if transport is Transport.STDIO:
177
+ # Local process: no credential in config, but it inherits the user's ambient identity.
178
+ return AuthHint(method=AuthMethod.NONE)
179
+ # Remote with no credential in config: either open, or OAuth negotiated at connect time.
180
+ return AuthHint(method=AuthMethod.UNKNOWN)
181
+
182
+
183
+ # Keys clients use to skip per-call confirmation: Cline/Roo `alwaysAllow`, `autoApprove`
184
+ # (list of tool names), and Gemini CLI `trust: true` (all tools).
185
+ _AUTO_APPROVE_LIST_KEYS = ("alwaysAllow", "autoApprove")
186
+ _MAX_APPROVAL_ENTRIES = 1000
187
+
188
+
189
+ def parse_approval(name: str, entry: dict[str, Any]) -> ApprovalConfig:
190
+ tools: list[str] = []
191
+ sources: list[str] = []
192
+ for key in _AUTO_APPROVE_LIST_KEYS:
193
+ value = entry.get(key)
194
+ if value is None:
195
+ continue
196
+ if not isinstance(value, list) or not all(isinstance(t, str) for t in value):
197
+ raise ConfigError(f"server {name!r}: {key!r} must be an array of tool names")
198
+ if len(value) > _MAX_APPROVAL_ENTRIES:
199
+ raise ConfigError(f"server {name!r}: {key!r} has too many entries")
200
+ tools.extend(value)
201
+ sources.append(key)
202
+ trust = entry.get("trust")
203
+ if trust is not None and not isinstance(trust, bool):
204
+ raise ConfigError(f"server {name!r}: 'trust' must be true or false")
205
+ if trust:
206
+ sources.append("trust")
207
+ return ApprovalConfig(
208
+ auto_approve_all=bool(trust), auto_approved_tools=sorted(set(tools)), sources=sources
209
+ )
210
+
211
+
212
+ def build_server(
213
+ name: str,
214
+ entry: Any,
215
+ *,
216
+ config_path: str,
217
+ text: str,
218
+ section_start: int,
219
+ base_dir: Path,
220
+ ) -> Server:
221
+ """Normalize one server entry. Raw values go to LaunchSpec; everything else is redacted."""
222
+ if not isinstance(entry, dict):
223
+ raise ConfigError(f"server {name!r}: entry must be an object")
224
+
225
+ url_value = entry.get("url") or entry.get("serverUrl")
226
+ url = str(url_value) if url_value else None
227
+ command = entry.get("command")
228
+ if command is not None and not isinstance(command, str):
229
+ raise ConfigError(f"server {name!r}: 'command' must be a string")
230
+ if not command and not url:
231
+ raise ConfigError(f"server {name!r}: needs either 'command' or 'url'")
232
+
233
+ try:
234
+ transport = infer_transport(entry, url)
235
+ args = _str_list(entry.get("args"), "args")
236
+ env = _str_dict(entry.get("env"), "env")
237
+ headers = _str_dict(entry.get("headers"), "headers")
238
+ except ConfigError as exc:
239
+ raise ConfigError(f"server {name!r}: {exc}") from None
240
+ env_file = entry.get("envFile")
241
+ cwd = entry.get("cwd")
242
+
243
+ for value in [*env.values(), *headers.values()]:
244
+ known_secrets.add(value)
245
+
246
+ return Server(
247
+ name=name,
248
+ transport=transport,
249
+ command=redact_text(command) if command else None,
250
+ args=redact_args(args),
251
+ url=redact_url(url) if url else None,
252
+ env={k: redact_value(v) for k, v in env.items()},
253
+ headers={k: redact_value(v) for k, v in headers.items()},
254
+ auth=infer_auth(transport, env, headers, args, url, env_file if env_file else None),
255
+ location=SourceLocation(path=config_path, line=find_key_line(text, name, section_start)),
256
+ disabled=bool(entry.get("disabled", False)),
257
+ approval=parse_approval(name, entry),
258
+ launch=LaunchSpec(
259
+ command=command,
260
+ args=args,
261
+ env=env,
262
+ env_file=str(env_file) if env_file else None,
263
+ cwd=str(cwd) if cwd else None,
264
+ url=url,
265
+ headers=headers,
266
+ # Absolute, so ${workspaceFolder} and the server cwd do not depend on how the
267
+ # scan path was written (never serialized, so it does not leak into output).
268
+ base_dir=str(base_dir.resolve()),
269
+ ),
270
+ )
271
+
272
+
273
+ def parse_server_map(
274
+ servers: Any,
275
+ *,
276
+ config_path: str,
277
+ text: str,
278
+ section_key: str,
279
+ base_dir: Path,
280
+ search_from: int = 0,
281
+ ) -> list[Server]:
282
+ if servers is None:
283
+ return []
284
+ if not isinstance(servers, dict):
285
+ raise ConfigError(f"{section_key!r} must be an object mapping names to servers")
286
+ start = section_offset(text, section_key, search_from)
287
+ return [
288
+ build_server(
289
+ str(name),
290
+ entry,
291
+ config_path=config_path,
292
+ text=text,
293
+ section_start=start,
294
+ base_dir=base_dir,
295
+ )
296
+ for name, entry in servers.items()
297
+ ]