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,264 @@
1
+ """Per-agent capability graph: the union of tool capabilities with per-tool provenance.
2
+
3
+ An agent is one client config. Only enabled servers count. A tool contributes to the
4
+ graph only if it was enumerated and classified; everything else is reported as
5
+ "not assessed" rather than guessed at.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ from dataclasses import dataclass, field
12
+ from enum import StrEnum
13
+
14
+ from agentcapmap.findings import AgentCapabilities, NotAssessed, ToolRef
15
+ from agentcapmap.graph.server_attrs import LaunchSource, SourceTrust, launch_source, source_trust
16
+ from agentcapmap.models import (
17
+ Agent,
18
+ ClassificationStatus,
19
+ EnumerationStatus,
20
+ Inventory,
21
+ Server,
22
+ ToolClassification,
23
+ ToolDefinition,
24
+ ToolTags,
25
+ Transport,
26
+ )
27
+ from agentcapmap.taxonomy import AXES, DEFAULTS
28
+
29
+
30
+ class ApprovalGate(StrEnum):
31
+ REQUIRED = "required" # reserved: a config that provably requires confirmation
32
+ NONE = "none" # config explicitly auto-approves the tool
33
+ UNKNOWN = "unknown" # config does not say; treated as "no gate" by default rules
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ServerView:
38
+ agent_id: str
39
+ server: Server
40
+ launch: LaunchSource
41
+ trust: SourceTrust
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ return self.server.name
46
+
47
+ @property
48
+ def remote(self) -> bool:
49
+ return self.server.transport is not Transport.STDIO
50
+
51
+ def field(self, name: str) -> str | bool:
52
+ if name == "auth":
53
+ return self.server.auth.method.value
54
+ if name == "transport":
55
+ return self.server.transport.value
56
+ if name == "remote":
57
+ return self.remote
58
+ if name == "version_pinned":
59
+ return self.launch.pin.value
60
+ if name == "source_trust":
61
+ return self.trust.value
62
+ raise KeyError(name)
63
+
64
+ def phrase(self, name: str) -> str:
65
+ if name == "auth":
66
+ evidence = ", ".join(self.server.auth.evidence) or "no credentials in config"
67
+ return f"auth={self.server.auth.method.value} ({evidence})"
68
+ if name == "version_pinned":
69
+ return f"launch spec: {self.launch.spec}" if self.launch.spec else "no package spec"
70
+ if name == "source_trust":
71
+ return f"source: {self.launch.source_id or 'unidentified'}"
72
+ if name in ("remote", "transport"):
73
+ return f"transport={self.server.transport.value}"
74
+ raise KeyError(name)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ToolView:
79
+ server: ServerView
80
+ tool: ToolDefinition
81
+ classification: ToolClassification
82
+ tags: ToolTags
83
+ approval_gate: ApprovalGate
84
+ approval_source: str | None
85
+
86
+ @property
87
+ def ref(self) -> str:
88
+ return f"{self.server.name}/{self.tool.name}"
89
+
90
+ def non_default_axes(self) -> list[str]:
91
+ return [axis for axis in AXES if getattr(self.tags, axis) != DEFAULTS[axis]]
92
+
93
+ def field(self, name: str) -> str | bool:
94
+ if name in AXES:
95
+ value: str | bool = getattr(self.tags, name)
96
+ return value
97
+ if name == "suspicious_description":
98
+ flag = self.classification.suspicious_description
99
+ return bool(flag and flag.flag)
100
+ if name == "any_capability":
101
+ return bool(self.non_default_axes())
102
+ if name == "approval_gate":
103
+ return self.approval_gate.value
104
+ raise KeyError(name)
105
+
106
+ def phrase(self, name: str) -> str | None:
107
+ if name in AXES:
108
+ return self.classification.evidence.get(name)
109
+ if name == "suspicious_description":
110
+ flag = self.classification.suspicious_description
111
+ return flag.reason if flag and flag.flag else None
112
+ if name == "any_capability":
113
+ parts = [
114
+ f"{axis}: {self.classification.evidence[axis]}"
115
+ for axis in self.non_default_axes()
116
+ if axis in self.classification.evidence
117
+ ]
118
+ return "; ".join(parts) or None
119
+ if name == "approval_gate":
120
+ source = self.approval_source
121
+ if self.approval_gate is ApprovalGate.NONE:
122
+ if source in ("alwaysAllow", "autoApprove", "trust"):
123
+ return f"auto-approved via config key {source!r}"
124
+ return f"auto-approved: {source}" if source else "auto-approved"
125
+ if self.approval_gate is ApprovalGate.REQUIRED:
126
+ return f"approval required: {source}" if source else "approval required"
127
+ return "no approval setting found in config"
128
+ raise KeyError(name)
129
+
130
+ def unverified(self, name: str) -> bool:
131
+ return name in self.classification.unverified_evidence
132
+
133
+
134
+ @dataclass
135
+ class AgentView:
136
+ agent: Agent
137
+ servers: list[ServerView] = field(default_factory=list)
138
+ # Classified tools only: the inputs to tag-based rules.
139
+ tools: list[ToolView] = field(default_factory=list)
140
+ # Every enumerated tool name per server, classified or not (for shadowing checks).
141
+ tool_names: dict[str, list[str]] = field(default_factory=dict)
142
+ not_assessed: list[NotAssessed] = field(default_factory=list)
143
+
144
+
145
+ def _matches(patterns: list[str], tool_name: str) -> bool:
146
+ return any(fnmatch.fnmatchcase(tool_name, p) for p in patterns)
147
+
148
+
149
+ def _approval(server: Server, tool_name: str) -> tuple[ApprovalGate, str | None] | None:
150
+ """The tool's approval gate and its evidence, or None if the client removes the tool."""
151
+ approval = server.approval
152
+ if _matches(approval.denied_patterns, tool_name):
153
+ return None # deny rule: the client never offers this tool to the model
154
+ if approval.auto_approve_all:
155
+ return ApprovalGate.NONE, next(iter(approval.sources), "trust")
156
+ if tool_name in approval.auto_approved_tools:
157
+ key = next((s for s in approval.sources if s != "trust"), "alwaysAllow")
158
+ return ApprovalGate.NONE, key
159
+ if _matches(approval.auto_approved_patterns, tool_name):
160
+ return ApprovalGate.NONE, "permissions.allow rule"
161
+ if approval.default_gate == "blocked":
162
+ return None # calls that would need approval are refused outright
163
+ if approval.default_gate == "none":
164
+ return ApprovalGate.NONE, approval.default_reason or None
165
+ if approval.default_gate == "required":
166
+ return ApprovalGate.REQUIRED, approval.default_reason or None
167
+ return ApprovalGate.UNKNOWN, None
168
+
169
+
170
+ def build_agent_view(agent: Agent, trusted_sources: list[str] | None = None) -> AgentView:
171
+ view = AgentView(agent=agent)
172
+ for server in agent.servers:
173
+ if server.disabled:
174
+ continue # not reachable by the agent
175
+ launch = launch_source(server)
176
+ sv = ServerView(
177
+ agent_id=agent.id,
178
+ server=server,
179
+ launch=launch,
180
+ trust=source_trust(launch.source_id, trusted_sources),
181
+ )
182
+ view.servers.append(sv)
183
+
184
+ enumeration = server.enumeration
185
+ if enumeration is None:
186
+ view.not_assessed.append(
187
+ NotAssessed(
188
+ agent_id=agent.id,
189
+ server=server.name,
190
+ reason="tools not enumerated (run scan with --enumerate)",
191
+ )
192
+ )
193
+ continue
194
+ if enumeration.status is not EnumerationStatus.OK:
195
+ reason = f"enumeration {enumeration.status.value}"
196
+ if enumeration.error:
197
+ reason += f": {enumeration.error}"
198
+ view.not_assessed.append(
199
+ NotAssessed(agent_id=agent.id, server=server.name, reason=reason)
200
+ )
201
+ continue
202
+
203
+ reachable = [t for t in enumeration.tools if _approval(server, t.name) is not None]
204
+ view.tool_names[server.name] = [t.name for t in reachable]
205
+ for tool in reachable:
206
+ result = tool.classification
207
+ if result is None or result.status is not ClassificationStatus.CLASSIFIED:
208
+ reason = (
209
+ "not classified (run with --classify)"
210
+ if result is None
211
+ else f"unclassified: {result.error or 'no reason recorded'}"
212
+ )
213
+ view.not_assessed.append(
214
+ NotAssessed(
215
+ agent_id=agent.id, server=server.name, tool=tool.name, reason=reason
216
+ )
217
+ )
218
+ continue
219
+ if result.tags is None: # classified results always carry tags
220
+ continue
221
+ approval = _approval(server, tool.name)
222
+ if approval is None:
223
+ continue
224
+ gate, gate_source = approval
225
+ view.tools.append(
226
+ ToolView(
227
+ server=sv,
228
+ tool=tool,
229
+ classification=result,
230
+ tags=result.tags,
231
+ approval_gate=gate,
232
+ approval_source=gate_source,
233
+ )
234
+ )
235
+ return view
236
+
237
+
238
+ def capabilities(view: AgentView) -> AgentCapabilities:
239
+ caps: dict[str, list[ToolRef]] = {}
240
+ for tv in view.tools:
241
+ for axis in tv.non_default_axes():
242
+ key = f"{axis}={str(getattr(tv.tags, axis)).lower()}"
243
+ caps.setdefault(key, []).append(
244
+ ToolRef(
245
+ server=tv.server.name,
246
+ tool=tv.tool.name,
247
+ evidence=tv.phrase(axis),
248
+ unverified=tv.unverified(axis),
249
+ )
250
+ )
251
+ for refs in caps.values():
252
+ refs.sort(key=lambda r: (r.server, r.tool))
253
+ return AgentCapabilities(
254
+ agent_id=view.agent.id,
255
+ client=view.agent.client,
256
+ config_path=view.agent.config_path,
257
+ capabilities=dict(sorted(caps.items())),
258
+ tools_total=sum(len(names) for names in view.tool_names.values()),
259
+ tools_assessed=len(view.tools),
260
+ )
261
+
262
+
263
+ def build_views(inventory: Inventory, trusted_sources: list[str] | None = None) -> list[AgentView]:
264
+ return [build_agent_view(agent, trusted_sources) for agent in inventory.agents]
@@ -0,0 +1,164 @@
1
+ """Server-level attributes derived from config alone (never from the LLM).
2
+
3
+ - version pinning: is the launched code fixed to an exact version or digest?
4
+ - source id: a stable name for where the code comes from, for trust allowlists.
5
+
6
+ Parsing uses the redacted command line stored in the inventory, so it works on saved
7
+ inventories too. Package names and versions are not secrets and survive redaction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import re
14
+ from dataclasses import dataclass
15
+ from enum import StrEnum
16
+ from pathlib import PurePath
17
+ from urllib.parse import urlsplit
18
+
19
+ from agentcapmap.models import Server, Transport
20
+
21
+
22
+ class PinStatus(StrEnum):
23
+ PINNED = "pinned"
24
+ UNPINNED = "unpinned"
25
+ LOCAL = "local" # code in a local file; versioned with the repo or machine
26
+ NOT_APPLICABLE = "not_applicable" # remote server: nothing to pin client-side
27
+ UNKNOWN = "unknown"
28
+
29
+
30
+ class SourceTrust(StrEnum):
31
+ TRUSTED = "trusted"
32
+ UNTRUSTED = "untrusted"
33
+ UNKNOWN = "unknown" # no trusted_sources configured
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class LaunchSource:
38
+ pin: PinStatus
39
+ source_id: str | None # e.g. "npm:@modelcontextprotocol/server-filesystem"
40
+ spec: str | None = None # the package/image spec as written
41
+
42
+
43
+ _EXACT_SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-+]+)?$")
44
+ _EXACT_PEP440 = re.compile(r"^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?$")
45
+ _NPM_RUNNERS = {"npx", "bunx"}
46
+ _PYTHON_RUNNERS = {"uvx"}
47
+ _CONTAINER_RUNNERS = {"docker", "podman"}
48
+ _SCRIPT_RUNNERS = {"node", "python", "python3", "deno", "bun", "tsx", "ts-node"}
49
+ _SCRIPT_SUFFIXES = (".js", ".mjs", ".cjs", ".ts", ".py")
50
+ # docker/podman run flags that take a separate value argument.
51
+ _CONTAINER_VALUE_FLAGS = frozenset(
52
+ {
53
+ "-e", "--env", "--env-file", "-v", "--volume", "--mount", "--name", "-p", "--publish",
54
+ "--network", "--net", "-w", "--workdir", "-u", "--user", "--entrypoint", "-l",
55
+ "--label", "--platform", "--pull", "--cpus", "-m", "--memory", "--add-host",
56
+ "--cap-add", "--cap-drop", "--security-opt", "-h", "--hostname", "--ulimit",
57
+ "--tmpfs", "--device", "--dns", "--restart", "--runtime", "--shm-size", "--ipc",
58
+ "--pid", "--userns", "--log-driver", "--log-opt", "--gpus",
59
+ }
60
+ ) # fmt: skip
61
+
62
+
63
+ def _program(command: str | None) -> str:
64
+ if not command:
65
+ return ""
66
+ name = PurePath(command.replace("\\", "/")).name.lower()
67
+ return name.removesuffix(".exe").removesuffix(".cmd").removesuffix(".bat")
68
+
69
+
70
+ def _first_positional(args: list[str], value_flags: frozenset[str] = frozenset()) -> int | None:
71
+ i = 0
72
+ while i < len(args):
73
+ arg = args[i]
74
+ if arg == "--":
75
+ return i + 1 if i + 1 < len(args) else None
76
+ if arg.startswith("-"):
77
+ if arg in value_flags and "=" not in arg:
78
+ i += 2
79
+ continue
80
+ i += 1
81
+ continue
82
+ return i
83
+ return None
84
+
85
+
86
+ def _npm_spec(spec: str) -> LaunchSource:
87
+ # "@scope/name@1.2.3", "name@1.2.3", "name@latest", "name"
88
+ at = spec.rfind("@")
89
+ name, version = (spec[:at], spec[at + 1 :]) if at > 0 else (spec, "")
90
+ pin = PinStatus.PINNED if _EXACT_SEMVER.match(version) else PinStatus.UNPINNED
91
+ return LaunchSource(pin=pin, source_id=f"npm:{name}", spec=spec)
92
+
93
+
94
+ def _pypi_spec(spec: str) -> LaunchSource:
95
+ # "name==1.2.3", "name@1.2.3" (uvx), "name@latest", "name>=1", "name"
96
+ if "==" in spec:
97
+ name, version = spec.split("==", 1)
98
+ elif "@" in spec:
99
+ name, version = spec.split("@", 1)
100
+ else:
101
+ name, version = re.split(r"[<>=!~\[]", spec, maxsplit=1)[0], ""
102
+ pin = PinStatus.PINNED if _EXACT_PEP440.match(version) else PinStatus.UNPINNED
103
+ return LaunchSource(pin=pin, source_id=f"pypi:{name.strip().lower()}", spec=spec)
104
+
105
+
106
+ def _image_spec(image: str) -> LaunchSource:
107
+ if "@sha256:" in image:
108
+ name = image.split("@", 1)[0]
109
+ return LaunchSource(pin=PinStatus.PINNED, source_id=f"docker:{name}", spec=image)
110
+ # Tags are mutable, so anything short of a digest is not pinned.
111
+ last = image.rsplit("/", 1)[-1]
112
+ name = image.rsplit(":", 1)[0] if ":" in last else image
113
+ return LaunchSource(pin=PinStatus.UNPINNED, source_id=f"docker:{name}", spec=image)
114
+
115
+
116
+ def launch_source(server: Server) -> LaunchSource:
117
+ if server.transport is not Transport.STDIO:
118
+ host = (urlsplit(server.url or "").hostname or "").lower()
119
+ return LaunchSource(PinStatus.NOT_APPLICABLE, f"url:{host}" if host else None)
120
+
121
+ program, args = _program(server.command), server.args
122
+ if program in _NPM_RUNNERS or (program == "npm" and args[:1] == ["exec"]):
123
+ rest = args[1:] if program == "npm" else args
124
+ idx = _first_positional(rest, frozenset({"-p", "--package"}))
125
+ if idx is not None:
126
+ return _npm_spec(rest[idx])
127
+ elif program in ("pnpm", "yarn") and args[:1] == ["dlx"]:
128
+ idx = _first_positional(args[1:])
129
+ if idx is not None:
130
+ return _npm_spec(args[1:][idx])
131
+ elif program in _PYTHON_RUNNERS or (program == "pipx" and args[:1] == ["run"]):
132
+ rest = args[1:] if program == "pipx" else args
133
+ if "--from" in rest and rest.index("--from") + 1 < len(rest):
134
+ return _pypi_spec(rest[rest.index("--from") + 1])
135
+ idx = _first_positional(rest, frozenset({"--python", "--with", "-p"}))
136
+ if idx is not None:
137
+ return _pypi_spec(rest[idx])
138
+ elif program in _CONTAINER_RUNNERS and args[:1] == ["run"]:
139
+ idx = _first_positional(args[1:], _CONTAINER_VALUE_FLAGS)
140
+ if idx is not None:
141
+ return _image_spec(args[1:][idx])
142
+ elif program in _SCRIPT_RUNNERS:
143
+ idx = _first_positional(args)
144
+ if idx is not None and args[idx].lower().endswith(_SCRIPT_SUFFIXES):
145
+ return LaunchSource(PinStatus.LOCAL, f"local:{args[idx]}", spec=args[idx])
146
+ # An explicit path to a script or binary: code already on disk, not fetched at launch.
147
+ if server.command and (
148
+ server.command.lower().endswith(_SCRIPT_SUFFIXES)
149
+ or "/" in server.command
150
+ or "\\" in server.command
151
+ ):
152
+ return LaunchSource(PinStatus.LOCAL, f"local:{server.command}", spec=server.command)
153
+ return LaunchSource(PinStatus.UNKNOWN, None)
154
+
155
+
156
+ def source_trust(source_id: str | None, trusted_sources: list[str] | None) -> SourceTrust:
157
+ if not trusted_sources:
158
+ return SourceTrust.UNKNOWN
159
+ if source_id is None:
160
+ return SourceTrust.UNTRUSTED
161
+ # fnmatchcase: case-sensitive, no filesystem semantics; patterns come from the policy.
162
+ if any(fnmatch.fnmatchcase(source_id, pattern) for pattern in trusted_sources):
163
+ return SourceTrust.TRUSTED
164
+ return SourceTrust.UNTRUSTED
agentcapmap/limits.py ADDED
@@ -0,0 +1,35 @@
1
+ """Resource limits for untrusted input (config files, MCP server output).
2
+
3
+ A hostile MCP server or config file must not be able to exhaust memory or CPU, or
4
+ crash a reader thread. Everything read from outside goes through these bounds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Final
11
+
12
+ # One JSON-RPC message (stdio line, HTTP body, SSE event).
13
+ MAX_MESSAGE_BYTES: Final = 8 * 1024 * 1024
14
+ # One config file.
15
+ MAX_CONFIG_BYTES: Final = 4 * 1024 * 1024
16
+ # Tools accepted from a single server across all tools/list pages.
17
+ MAX_TOOLS_PER_SERVER: Final = 5000
18
+ # Unanswered server-to-client messages buffered while waiting for a response.
19
+ MAX_QUEUED_MESSAGES: Final = 1000
20
+ # Longest stderr line kept for diagnostics.
21
+ MAX_STDERR_LINE_CHARS: Final = 2000
22
+
23
+
24
+ class UntrustedJSONError(ValueError):
25
+ pass
26
+
27
+
28
+ def loads_untrusted(text: str | bytes) -> Any:
29
+ """json.loads that turns pathological input (e.g. deep nesting) into a ValueError."""
30
+ try:
31
+ return json.loads(text)
32
+ except RecursionError:
33
+ raise UntrustedJSONError("JSON nested too deeply") from None
34
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
35
+ raise UntrustedJSONError(f"invalid JSON: {exc}") from None
agentcapmap/models.py ADDED
@@ -0,0 +1,186 @@
1
+ """Internal inventory model: Agent -> [Server] -> [Tool].
2
+
3
+ Serialized fields are always redacted. Raw launch details needed to actually start a
4
+ server live in `LaunchSpec`, which is excluded from serialization and from repr.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import StrEnum
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from agentcapmap import __version__
15
+
16
+ INVENTORY_SCHEMA_VERSION = "1"
17
+
18
+
19
+ class Transport(StrEnum):
20
+ STDIO = "stdio"
21
+ HTTP = "http" # Streamable HTTP
22
+ SSE = "sse" # legacy HTTP+SSE
23
+ WS = "ws" # WebSocket (Claude Code); listed, not enumerated
24
+
25
+
26
+ class AuthMethod(StrEnum):
27
+ NONE = "none"
28
+ STATIC = "static"
29
+ OAUTH = "oauth"
30
+ UNKNOWN = "unknown"
31
+
32
+
33
+ class AuthHint(BaseModel):
34
+ method: AuthMethod
35
+ # Where the credential comes from, by name only: "env:GITHUB_TOKEN", "header:Authorization".
36
+ evidence: list[str] = Field(default_factory=list)
37
+
38
+
39
+ class SourceLocation(BaseModel):
40
+ path: str
41
+ line: int | None = None
42
+
43
+
44
+ class LaunchSpec(BaseModel):
45
+ """Unredacted launch details. Never serialized, never repr'd."""
46
+
47
+ model_config = ConfigDict(frozen=True)
48
+
49
+ command: str | None = None
50
+ args: list[str] = Field(default_factory=list)
51
+ env: dict[str, str] = Field(default_factory=dict)
52
+ env_file: str | None = None
53
+ cwd: str | None = None
54
+ url: str | None = None
55
+ headers: dict[str, str] = Field(default_factory=dict)
56
+ # Directory used to resolve ${workspaceFolder} and relative paths.
57
+ base_dir: str | None = None
58
+
59
+ def __repr__(self) -> str:
60
+ return "LaunchSpec(<redacted>)"
61
+
62
+ __str__ = __repr__
63
+
64
+
65
+ class ToolTags(BaseModel):
66
+ """The six taxonomy axes (see schemas/tool-tags.schema.json)."""
67
+
68
+ data_access: Literal["none", "private_read", "secrets_read"]
69
+ untrusted_input: bool
70
+ egress: Literal["none", "internal", "external"]
71
+ mutation: Literal["none", "reversible_write", "destructive"]
72
+ execution: Literal["none", "sandboxed", "host"]
73
+ privilege: Literal["none", "identity_admin"]
74
+
75
+
76
+ class SuspiciousDescription(BaseModel):
77
+ flag: bool
78
+ reason: str = ""
79
+
80
+
81
+ class ClassificationStatus(StrEnum):
82
+ CLASSIFIED = "classified"
83
+ UNCLASSIFIED = "unclassified"
84
+
85
+
86
+ class ToolClassification(BaseModel):
87
+ status: ClassificationStatus
88
+ # Set only when status is "classified".
89
+ tags: ToolTags | None = None
90
+ confidence: float | None = None
91
+ # Per-axis phrase justifying each non-default tag.
92
+ evidence: dict[str, str] = Field(default_factory=dict)
93
+ # Axes whose evidence phrase could not be found in the tool definition text.
94
+ unverified_evidence: list[str] = Field(default_factory=list)
95
+ suspicious_description: SuspiciousDescription | None = None
96
+ # Why the tool is unclassified.
97
+ error: str | None = None
98
+ # Provenance: results are only comparable under the same versions and model.
99
+ model: str | None = None
100
+ taxonomy_version: str
101
+ prompt_version: str
102
+ from_cache: bool = False
103
+
104
+
105
+ class ToolDefinition(BaseModel):
106
+ name: str
107
+ description: str = ""
108
+ input_schema: dict[str, Any] = Field(default_factory=dict)
109
+ annotations: dict[str, Any] | None = None
110
+ definition_hash: str
111
+ classification: ToolClassification | None = None
112
+
113
+
114
+ class EnumerationStatus(StrEnum):
115
+ OK = "ok"
116
+ ERROR = "error"
117
+ TIMEOUT = "timeout"
118
+ AUTH_REQUIRED = "auth_required"
119
+ SKIPPED = "skipped"
120
+
121
+
122
+ class EnumerationResult(BaseModel):
123
+ status: EnumerationStatus
124
+ error: str | None = None
125
+ protocol_version: str | None = None
126
+ server_info: dict[str, Any] | None = None
127
+ tools: list[ToolDefinition] = Field(default_factory=list)
128
+ # Hash over the sorted tool definition hashes: changes if any tool changes.
129
+ definition_hash: str | None = None
130
+
131
+
132
+ class ApprovalConfig(BaseModel):
133
+ """Whether a person must approve each tool call, as far as the client config shows.
134
+
135
+ Most clients do not record this, so `default_gate` is "unknown" unless a format knows
136
+ better (Claude Code: "required" in its default permission mode).
137
+ """
138
+
139
+ auto_approve_all: bool = False
140
+ # Exact tool names that skip approval (e.g. Cline `alwaysAllow`).
141
+ auto_approved_tools: list[str] = Field(default_factory=list)
142
+ # Glob patterns over tool names that skip approval (e.g. Claude Code allow rules).
143
+ auto_approved_patterns: list[str] = Field(default_factory=list)
144
+ # Glob patterns over tool names the client removes from the agent (deny rules).
145
+ denied_patterns: list[str] = Field(default_factory=list)
146
+ # Gate for tools no rule matches: "required" | "none" | "unknown" | "blocked"
147
+ # ("blocked": the client refuses calls that would need approval, e.g. dontAsk mode).
148
+ default_gate: Literal["required", "none", "unknown", "blocked"] = "unknown"
149
+ default_reason: str = ""
150
+ # Config keys or rules the settings came from, for evidence.
151
+ sources: list[str] = Field(default_factory=list)
152
+
153
+
154
+ class Server(BaseModel):
155
+ name: str
156
+ transport: Transport
157
+ command: str | None = None
158
+ args: list[str] = Field(default_factory=list)
159
+ url: str | None = None
160
+ env: dict[str, str] = Field(default_factory=dict)
161
+ headers: dict[str, str] = Field(default_factory=dict)
162
+ auth: AuthHint
163
+ location: SourceLocation
164
+ disabled: bool = False
165
+ approval: ApprovalConfig = Field(default_factory=ApprovalConfig)
166
+ enumeration: EnumerationResult | None = None
167
+ launch: LaunchSpec = Field(default_factory=LaunchSpec, exclude=True, repr=False)
168
+
169
+
170
+ class Agent(BaseModel):
171
+ id: str
172
+ client: str
173
+ config_path: str
174
+ servers: list[Server] = Field(default_factory=list)
175
+
176
+
177
+ class DiscoveryError(BaseModel):
178
+ path: str
179
+ message: str
180
+
181
+
182
+ class Inventory(BaseModel):
183
+ schema_version: str = INVENTORY_SCHEMA_VERSION
184
+ tool_version: str = __version__
185
+ agents: list[Agent] = Field(default_factory=list)
186
+ errors: list[DiscoveryError] = Field(default_factory=list)