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
+ """SARIF 2.1.0 output for GitHub code scanning.
2
+
3
+ - Rules come from the policy; GitHub ranks them by `security-severity`.
4
+ - Each result points at the config file and line where the (first) server is defined.
5
+ - Locations must be repository-relative. Absolute paths, `..` segments and URLs are
6
+ never emitted; GitHub rejects results without a location, so such findings are left
7
+ out of the SARIF (still present in JSON/Markdown) and a notification says so.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ from agentcapmap import __version__
17
+ from agentcapmap.findings import Evaluation, Finding, RuleInfo, ServerLocation, Severity
18
+ from agentcapmap.report._text import clean
19
+
20
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
21
+ INFORMATION_URI = "https://github.com/NKwolv/agent-capmap"
22
+ FINGERPRINT_KEY = "agentcapmapFinding/v1"
23
+
24
+ _LEVEL = {
25
+ Severity.CRITICAL: "error",
26
+ Severity.HIGH: "error",
27
+ Severity.MEDIUM: "warning",
28
+ Severity.LOW: "note",
29
+ Severity.INFO: "note",
30
+ }
31
+ # GitHub buckets: >= 9.0 critical, 7.0-8.9 high, 4.0-6.9 medium, 0.1-3.9 low.
32
+ _SECURITY_SEVERITY = {
33
+ Severity.CRITICAL: "9.5",
34
+ Severity.HIGH: "8.0",
35
+ Severity.MEDIUM: "5.5",
36
+ Severity.LOW: "3.0",
37
+ Severity.INFO: "0.0",
38
+ }
39
+ _WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
40
+
41
+
42
+ def safe_uri(path: str | None) -> str | None:
43
+ """A repository-relative URI reference, or None if the path is not safely relative."""
44
+ if not path:
45
+ return None
46
+ normalized = path.replace("\\", "/")
47
+ if normalized.startswith("/") or _WINDOWS_DRIVE.match(normalized) or "://" in normalized:
48
+ return None
49
+ segments = normalized.split("/")
50
+ if any(seg in ("", ".", "..") for seg in segments):
51
+ return None
52
+ return quote(normalized, safe="/-._~")
53
+
54
+
55
+ def _location(loc: ServerLocation | None, path: str, line: int | None) -> dict[str, Any] | None:
56
+ uri = safe_uri(loc.path if loc else path)
57
+ if uri is None:
58
+ return None
59
+ physical: dict[str, Any] = {"artifactLocation": {"uri": uri, "uriBaseId": "%SRCROOT%"}}
60
+ start = loc.line if loc else line
61
+ if start and start > 0:
62
+ physical["region"] = {"startLine": start}
63
+ return {"physicalLocation": physical}
64
+
65
+
66
+ def _rule(rule: RuleInfo) -> dict[str, Any]:
67
+ message = clean(rule.message, 1000)
68
+ refs = ", ".join(clean(m, 64) for m in rule.maps_to)
69
+ help_text = message + (f" Maps to: {refs}." if refs else "")
70
+ return {
71
+ "id": rule.id,
72
+ "name": rule.id.replace("-", ""),
73
+ "shortDescription": {"text": clean(rule.message.split(". ")[0], 200)},
74
+ "fullDescription": {"text": message},
75
+ "help": {"text": help_text},
76
+ "helpUri": f"{INFORMATION_URI}/blob/main/docs/threat-model.md",
77
+ "defaultConfiguration": {"level": _LEVEL[rule.severity]},
78
+ "properties": {
79
+ "tags": ["security", "mcp", *(clean(m, 64) for m in rule.maps_to)],
80
+ "precision": "high",
81
+ "problem.severity": "error" if _LEVEL[rule.severity] == "error" else "warning",
82
+ "security-severity": _SECURITY_SEVERITY[rule.severity],
83
+ },
84
+ }
85
+
86
+
87
+ def _message(finding: Finding) -> str:
88
+ parts = [clean(finding.message, 600), f"Agent: {clean(finding.agent_id, 200)}."]
89
+ if finding.tools:
90
+ parts.append(f"Tools: {clean(', '.join(finding.tools), 400)}.")
91
+ evidence = [
92
+ f"{clean(ev.tool or ev.server, 80)} [{clean(k, 40)}]: {clean(v, 160)}"
93
+ for ev in finding.evidence
94
+ for k, v in ev.phrases.items()
95
+ ]
96
+ if evidence:
97
+ parts.append(
98
+ "Evidence: " + "; ".join(evidence[:8]) + ("; ..." if len(evidence) > 8 else "")
99
+ )
100
+ return " ".join(parts)
101
+
102
+
103
+ def _result(finding: Finding, rule_index: int) -> dict[str, Any] | None:
104
+ first = finding.server_locations[0] if finding.server_locations else None
105
+ path = finding.location.path if finding.location else finding.config_path
106
+ line = finding.location.line if finding.location else None
107
+ primary = _location(first, path, line)
108
+ if primary is None:
109
+ return None
110
+ related = []
111
+ for index, loc in enumerate(finding.server_locations[1:], start=1):
112
+ if (location := _location(loc, loc.path, loc.line)) is not None:
113
+ location["id"] = index
114
+ location["message"] = {"text": f"server {clean(loc.server, 100)}"}
115
+ related.append(location)
116
+ result: dict[str, Any] = {
117
+ "ruleId": finding.rule_id,
118
+ "ruleIndex": rule_index,
119
+ "level": _LEVEL[finding.severity],
120
+ "message": {"text": _message(finding)},
121
+ "locations": [primary],
122
+ "partialFingerprints": {FINGERPRINT_KEY: finding.fingerprint},
123
+ "properties": {
124
+ "severity": finding.severity.value,
125
+ "agent": clean(finding.agent_id, 200),
126
+ "servers": [clean(s, 100) for s in finding.servers],
127
+ "tools": [clean(t, 200) for t in finding.tools],
128
+ },
129
+ }
130
+ if related:
131
+ result["relatedLocations"] = related
132
+ return result
133
+
134
+
135
+ def render(evaluation: Evaluation) -> dict[str, Any]:
136
+ rules = [r for r in evaluation.rules if r.enabled]
137
+ rule_index = {r.id: i for i, r in enumerate(rules)}
138
+ results: list[dict[str, Any]] = []
139
+ omitted = 0
140
+ for finding in evaluation.findings:
141
+ result = _result(finding, rule_index[finding.rule_id])
142
+ if result is None:
143
+ omitted += 1
144
+ else:
145
+ results.append(result)
146
+
147
+ notifications: list[dict[str, Any]] = []
148
+ if evaluation.not_assessed:
149
+ notifications.append(
150
+ {
151
+ "level": "warning",
152
+ "descriptor": {"id": "not-assessed"},
153
+ "message": {
154
+ "text": f"{len(evaluation.not_assessed)} server(s) or tool(s) were not "
155
+ "assessed (not enumerated or not classified); findings may be incomplete."
156
+ },
157
+ }
158
+ )
159
+ for skipped in evaluation.skipped_rules:
160
+ notifications.append(
161
+ {
162
+ "level": "note",
163
+ "descriptor": {"id": "rule-skipped"},
164
+ "message": {
165
+ "text": f"Rule {skipped.rule_id} skipped: {clean(skipped.reason, 200)}"
166
+ },
167
+ }
168
+ )
169
+ if omitted:
170
+ notifications.append(
171
+ {
172
+ "level": "warning",
173
+ "descriptor": {"id": "result-omitted"},
174
+ "message": {
175
+ "text": f"{omitted} finding(s) omitted from SARIF because their config "
176
+ "file is outside the repository; see the JSON or Markdown report."
177
+ },
178
+ }
179
+ )
180
+
181
+ return {
182
+ "$schema": SARIF_SCHEMA,
183
+ "version": "2.1.0",
184
+ "runs": [
185
+ {
186
+ "tool": {
187
+ "driver": {
188
+ "name": "agent-capmap",
189
+ "version": __version__,
190
+ "semanticVersion": __version__,
191
+ "informationUri": INFORMATION_URI,
192
+ "rules": [_rule(r) for r in rules],
193
+ }
194
+ },
195
+ "invocations": [
196
+ {
197
+ "executionSuccessful": True,
198
+ "toolExecutionNotifications": notifications,
199
+ }
200
+ ],
201
+ "results": results,
202
+ "properties": {
203
+ "policy": evaluation.policy.model_dump(mode="json"),
204
+ "summary": evaluation.summary,
205
+ "baselineUsed": evaluation.baseline_used,
206
+ },
207
+ }
208
+ ],
209
+ }
@@ -0,0 +1,172 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/NKwolv/agent-capmap/schemas/policy.schema.json",
4
+ "title": "agent-capmap policy",
5
+ "description": "Deterministic rules evaluated per agent over the union of its tools. Policy format version 1, taxonomy version 1.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version", "taxonomy_version", "rules"],
9
+ "properties": {
10
+ "version": { "const": 1 },
11
+ "taxonomy_version": { "const": "1" },
12
+ "trusted_sources": {
13
+ "description": "Vetted server sources. Glob patterns over source ids: npm:<pkg>, pypi:<pkg>, docker:<image>, url:<host>, local:<path>. When set, servers matching none are 'untrusted'.",
14
+ "type": "array",
15
+ "maxItems": 1000,
16
+ "items": {
17
+ "type": "string",
18
+ "minLength": 5,
19
+ "maxLength": 256,
20
+ "pattern": "^(npm|pypi|docker|url|local):[^\\s]+$"
21
+ }
22
+ },
23
+ "rules": {
24
+ "type": "array",
25
+ "minItems": 1,
26
+ "maxItems": 500,
27
+ "items": { "$ref": "#/$defs/rule" }
28
+ }
29
+ },
30
+ "$defs": {
31
+ "rule": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["id", "severity", "message"],
35
+ "properties": {
36
+ "id": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]{1,31}$" },
37
+ "severity": { "enum": ["critical", "high", "medium", "low", "info"] },
38
+ "message": { "type": "string", "minLength": 1, "maxLength": 500 },
39
+ "enabled": { "type": "boolean" },
40
+ "maps_to": {
41
+ "type": "array",
42
+ "maxItems": 20,
43
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9.:_-]{1,64}$" }
44
+ },
45
+ "when_agent_has_all": { "$ref": "#/$defs/tool_predicates" },
46
+ "when_agent_has_any": { "$ref": "#/$defs/tool_predicates" },
47
+ "when_server": {
48
+ "oneOf": [
49
+ { "$ref": "#/$defs/server_predicate" },
50
+ {
51
+ "type": "array",
52
+ "minItems": 1,
53
+ "maxItems": 20,
54
+ "items": { "$ref": "#/$defs/server_predicate" }
55
+ }
56
+ ]
57
+ },
58
+ "when_hash_changed": { "const": true },
59
+ "when_tool_name_shadowed": { "const": true }
60
+ },
61
+ "anyOf": [
62
+ { "required": ["when_agent_has_all"] },
63
+ { "required": ["when_agent_has_any"] },
64
+ { "required": ["when_server"] },
65
+ { "required": ["when_hash_changed"] },
66
+ { "required": ["when_tool_name_shadowed"] }
67
+ ]
68
+ },
69
+ "tool_predicates": {
70
+ "type": "array",
71
+ "minItems": 1,
72
+ "maxItems": 20,
73
+ "items": { "$ref": "#/$defs/tool_predicate" }
74
+ },
75
+ "tool_predicate": {
76
+ "description": "All fields must match on the same tool. A list means any of the values.",
77
+ "type": "object",
78
+ "additionalProperties": false,
79
+ "minProperties": 1,
80
+ "properties": {
81
+ "data_access": { "$ref": "#/$defs/data_access" },
82
+ "untrusted_input": { "$ref": "#/$defs/boolean_or_list" },
83
+ "egress": { "$ref": "#/$defs/egress" },
84
+ "mutation": { "$ref": "#/$defs/mutation" },
85
+ "execution": { "$ref": "#/$defs/execution" },
86
+ "privilege": { "$ref": "#/$defs/privilege" },
87
+ "suspicious_description": { "type": "boolean" },
88
+ "any_capability": { "type": "boolean" },
89
+ "approval_gate": { "$ref": "#/$defs/approval_gate" }
90
+ }
91
+ },
92
+ "server_predicate": {
93
+ "description": "All fields must match on the same server. A list means any of the values.",
94
+ "type": "object",
95
+ "additionalProperties": false,
96
+ "minProperties": 1,
97
+ "properties": {
98
+ "auth": { "$ref": "#/$defs/auth" },
99
+ "transport": { "$ref": "#/$defs/transport" },
100
+ "remote": { "type": "boolean" },
101
+ "version_pinned": { "$ref": "#/$defs/version_pinned" },
102
+ "source_trust": { "$ref": "#/$defs/source_trust" }
103
+ }
104
+ },
105
+ "boolean_or_list": {
106
+ "oneOf": [
107
+ { "type": "boolean" },
108
+ { "type": "array", "minItems": 1, "maxItems": 2, "uniqueItems": true, "items": { "type": "boolean" } }
109
+ ]
110
+ },
111
+ "data_access": {
112
+ "oneOf": [
113
+ { "enum": ["none", "private_read", "secrets_read"] },
114
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "private_read", "secrets_read"] } }
115
+ ]
116
+ },
117
+ "egress": {
118
+ "oneOf": [
119
+ { "enum": ["none", "internal", "external"] },
120
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "internal", "external"] } }
121
+ ]
122
+ },
123
+ "mutation": {
124
+ "oneOf": [
125
+ { "enum": ["none", "reversible_write", "destructive"] },
126
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "reversible_write", "destructive"] } }
127
+ ]
128
+ },
129
+ "execution": {
130
+ "oneOf": [
131
+ { "enum": ["none", "sandboxed", "host"] },
132
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "sandboxed", "host"] } }
133
+ ]
134
+ },
135
+ "privilege": {
136
+ "oneOf": [
137
+ { "enum": ["none", "identity_admin"] },
138
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "identity_admin"] } }
139
+ ]
140
+ },
141
+ "approval_gate": {
142
+ "oneOf": [
143
+ { "enum": ["required", "none", "unknown"] },
144
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["required", "none", "unknown"] } }
145
+ ]
146
+ },
147
+ "auth": {
148
+ "oneOf": [
149
+ { "enum": ["none", "static", "oauth", "unknown"] },
150
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "static", "oauth", "unknown"] } }
151
+ ]
152
+ },
153
+ "transport": {
154
+ "oneOf": [
155
+ { "enum": ["stdio", "http", "sse", "ws"] },
156
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["stdio", "http", "sse", "ws"] } }
157
+ ]
158
+ },
159
+ "version_pinned": {
160
+ "oneOf": [
161
+ { "enum": ["pinned", "unpinned", "local", "not_applicable", "unknown"] },
162
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["pinned", "unpinned", "local", "not_applicable", "unknown"] } }
163
+ ]
164
+ },
165
+ "source_trust": {
166
+ "oneOf": [
167
+ { "enum": ["trusted", "untrusted", "unknown"] },
168
+ { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["trusted", "untrusted", "unknown"] } }
169
+ ]
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/NKwolv/agent-capmap/schemas/tool-tags.schema.json",
4
+ "title": "agent-capmap tool classification",
5
+ "description": "Classifier output for one MCP tool. taxonomy_version 1.",
6
+ "x-taxonomy-version": "1",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": ["tool", "tags", "confidence", "evidence", "suspicious_description"],
10
+ "properties": {
11
+ "tool": {
12
+ "type": "string",
13
+ "description": "Exact tool name being classified."
14
+ },
15
+ "tags": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "required": ["data_access", "untrusted_input", "egress", "mutation", "execution", "privilege"],
19
+ "properties": {
20
+ "data_access": { "type": "string", "enum": ["none", "private_read", "secrets_read"] },
21
+ "untrusted_input": { "type": "boolean" },
22
+ "egress": { "type": "string", "enum": ["none", "internal", "external"] },
23
+ "mutation": { "type": "string", "enum": ["none", "reversible_write", "destructive"] },
24
+ "execution": { "type": "string", "enum": ["none", "sandboxed", "host"] },
25
+ "privilege": { "type": "string", "enum": ["none", "identity_admin"] }
26
+ }
27
+ },
28
+ "confidence": {
29
+ "type": "number",
30
+ "minimum": 0,
31
+ "maximum": 1,
32
+ "description": "Overall confidence in the tags, 0 to 1."
33
+ },
34
+ "evidence": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "description": "Per-axis phrase from the tool's description or input schema justifying the tag. Required for every non-default tag.",
38
+ "properties": {
39
+ "data_access": { "type": "string" },
40
+ "untrusted_input": { "type": "string" },
41
+ "egress": { "type": "string" },
42
+ "mutation": { "type": "string" },
43
+ "execution": { "type": "string" },
44
+ "privilege": { "type": "string" }
45
+ }
46
+ },
47
+ "suspicious_description": {
48
+ "type": "object",
49
+ "additionalProperties": false,
50
+ "required": ["flag", "reason"],
51
+ "properties": {
52
+ "flag": { "type": "boolean" },
53
+ "reason": { "type": "string" }
54
+ }
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,56 @@
1
+ """Capability taxonomy v1: the six per-tool axes and their default (no-capability) values.
2
+
3
+ The JSON schema in `schemas/tool-tags.schema.json` is the source of truth for the
4
+ allowed values; this module mirrors it for typed access and loads the schema file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from functools import cache
11
+ from importlib import resources
12
+ from pathlib import Path
13
+ from typing import Any, Final
14
+
15
+ TAXONOMY_VERSION: Final = "1"
16
+
17
+ AXES: Final = ("data_access", "untrusted_input", "egress", "mutation", "execution", "privilege")
18
+
19
+ # The value meaning "this tool has no capability on this axis".
20
+ DEFAULTS: Final[dict[str, str | bool]] = {
21
+ "data_access": "none",
22
+ "untrusted_input": False,
23
+ "egress": "none",
24
+ "mutation": "none",
25
+ "execution": "none",
26
+ "privilege": "none",
27
+ }
28
+
29
+ _SCHEMA_FILE = "tool-tags.schema.json"
30
+
31
+
32
+ def data_file(directory: str, name: str) -> str:
33
+ """Read a bundled data file (schemas/, policies/): from the wheel, else the checkout."""
34
+ packaged = resources.files("agentcapmap").joinpath(directory, name)
35
+ if packaged.is_file():
36
+ return packaged.read_text(encoding="utf-8")
37
+ repo_copy = Path(__file__).resolve().parents[2] / directory / name
38
+ return repo_copy.read_text(encoding="utf-8")
39
+
40
+
41
+ @cache
42
+ def tool_tags_schema() -> dict[str, Any]:
43
+ schema: dict[str, Any] = json.loads(data_file("schemas", _SCHEMA_FILE))
44
+ if schema.get("x-taxonomy-version") != TAXONOMY_VERSION:
45
+ raise RuntimeError(
46
+ f"{_SCHEMA_FILE} is taxonomy version {schema.get('x-taxonomy-version')!r}, "
47
+ f"code expects {TAXONOMY_VERSION!r}"
48
+ )
49
+ return schema
50
+
51
+
52
+ def axis_values(axis: str) -> list[str | bool]:
53
+ spec = tool_tags_schema()["properties"]["tags"]["properties"][axis]
54
+ if spec["type"] == "boolean":
55
+ return [False, True]
56
+ return list(spec["enum"])