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