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,113 @@
|
|
|
1
|
+
# agent-capmap default policy.
|
|
2
|
+
#
|
|
3
|
+
# Rules are evaluated per agent (one client config) over the union of the tools it can
|
|
4
|
+
# reach. Copy this file and pass it with --policy to customize. Format reference:
|
|
5
|
+
# schemas/policy.schema.json and docs/threat-model.md.
|
|
6
|
+
#
|
|
7
|
+
# Condition semantics:
|
|
8
|
+
# when_server filters which servers count (a dict: all fields match; a list
|
|
9
|
+
# of dicts: any one matches). Alone, it reports matching servers.
|
|
10
|
+
# when_agent_has_all every predicate is satisfied by at least one tool
|
|
11
|
+
# when_agent_has_any at least one predicate is satisfied by at least one tool
|
|
12
|
+
# (fields inside one predicate must match on the same tool;
|
|
13
|
+
# a list value means "any of these values")
|
|
14
|
+
# when_hash_changed tool definitions differ from the --baseline file
|
|
15
|
+
# when_tool_name_shadowed the same tool name is exposed by more than one server
|
|
16
|
+
version: 1
|
|
17
|
+
taxonomy_version: "1"
|
|
18
|
+
|
|
19
|
+
# Uncomment and list your vetted sources to make CAP-007 also flag unvetted servers.
|
|
20
|
+
# trusted_sources:
|
|
21
|
+
# - npm:@modelcontextprotocol/*
|
|
22
|
+
# - docker:ghcr.io/github/github-mcp-server
|
|
23
|
+
# - url:mcp.sentry.dev
|
|
24
|
+
|
|
25
|
+
rules:
|
|
26
|
+
- id: CAP-001
|
|
27
|
+
severity: critical
|
|
28
|
+
when_agent_has_all:
|
|
29
|
+
- data_access: [private_read, secrets_read]
|
|
30
|
+
- untrusted_input: true
|
|
31
|
+
- egress: external
|
|
32
|
+
message: >-
|
|
33
|
+
Lethal trifecta: the agent can read private data, ingest attacker-controlled
|
|
34
|
+
content, and send data externally, so injected instructions can exfiltrate data.
|
|
35
|
+
maps_to: [OWASP-LLM01, OWASP-LLM02, OWASP-LLM06, MITRE-ATLAS-AML.T0051.001, MITRE-ATLAS-AML.T0086]
|
|
36
|
+
|
|
37
|
+
- id: CAP-002
|
|
38
|
+
severity: critical
|
|
39
|
+
when_agent_has_all:
|
|
40
|
+
- untrusted_input: true
|
|
41
|
+
- execution: host
|
|
42
|
+
message: >-
|
|
43
|
+
Untrusted content can reach host-level code execution: an injected instruction
|
|
44
|
+
can run commands on the machine.
|
|
45
|
+
maps_to: [OWASP-LLM01, OWASP-LLM06, MITRE-ATLAS-AML.T0051.001, MITRE-ATLAS-AML.T0053]
|
|
46
|
+
|
|
47
|
+
- id: CAP-003
|
|
48
|
+
severity: high
|
|
49
|
+
when_agent_has_any:
|
|
50
|
+
- mutation: destructive
|
|
51
|
+
approval_gate: [none, unknown]
|
|
52
|
+
- execution: host
|
|
53
|
+
approval_gate: [none, unknown]
|
|
54
|
+
message: >-
|
|
55
|
+
Destructive or host-execution tool without a detectable human approval gate.
|
|
56
|
+
("unknown" means the config does not show one; set approval_gate to [none] to
|
|
57
|
+
flag only explicit auto-approval.)
|
|
58
|
+
maps_to: [OWASP-LLM06, MITRE-ATLAS-AML.T0101, MITRE-ATLAS-AML.T0053]
|
|
59
|
+
|
|
60
|
+
- id: CAP-004
|
|
61
|
+
severity: high
|
|
62
|
+
when_agent_has_all:
|
|
63
|
+
- data_access: secrets_read
|
|
64
|
+
- egress: external
|
|
65
|
+
message: The agent can read secrets and send data externally.
|
|
66
|
+
maps_to: [OWASP-LLM02, OWASP-LLM06, MITRE-ATLAS-AML.T0098, MITRE-ATLAS-AML.T0086]
|
|
67
|
+
|
|
68
|
+
- id: CAP-005
|
|
69
|
+
severity: high
|
|
70
|
+
# Remote servers only: a local stdio server runs as the user rather than being
|
|
71
|
+
# reachable without authentication.
|
|
72
|
+
when_server:
|
|
73
|
+
remote: true
|
|
74
|
+
auth: none
|
|
75
|
+
when_agent_has_any:
|
|
76
|
+
- any_capability: true
|
|
77
|
+
message: Remote MCP server answers without authentication and exposes capabilities.
|
|
78
|
+
maps_to: [OWASP-LLM06, MITRE-ATLAS-AML.T0053]
|
|
79
|
+
|
|
80
|
+
- id: CAP-006
|
|
81
|
+
severity: high
|
|
82
|
+
when_hash_changed: true
|
|
83
|
+
message: >-
|
|
84
|
+
Tool definitions changed since the pinned baseline (possible rug pull). Review
|
|
85
|
+
the change before trusting this server again.
|
|
86
|
+
maps_to: [OWASP-LLM03, MITRE-ATLAS-AML.T0109, MITRE-ATLAS-AML.T0010.005]
|
|
87
|
+
|
|
88
|
+
- id: CAP-007
|
|
89
|
+
severity: medium
|
|
90
|
+
when_server:
|
|
91
|
+
- version_pinned: unpinned
|
|
92
|
+
- source_trust: untrusted
|
|
93
|
+
message: >-
|
|
94
|
+
Server is not pinned to an exact version or digest, or does not come from a
|
|
95
|
+
vetted source, so its code can change without review.
|
|
96
|
+
maps_to: [OWASP-LLM03, MITRE-ATLAS-AML.T0010.005]
|
|
97
|
+
|
|
98
|
+
- id: CAP-008
|
|
99
|
+
severity: high
|
|
100
|
+
when_agent_has_any:
|
|
101
|
+
- suspicious_description: true
|
|
102
|
+
message: >-
|
|
103
|
+
Tool description contains instructions aimed at the model (possible tool
|
|
104
|
+
poisoning).
|
|
105
|
+
maps_to: [OWASP-LLM01, OWASP-LLM03, MITRE-ATLAS-AML.T0110]
|
|
106
|
+
|
|
107
|
+
- id: CAP-009
|
|
108
|
+
severity: medium
|
|
109
|
+
when_tool_name_shadowed: true
|
|
110
|
+
message: >-
|
|
111
|
+
The same tool name is exposed by more than one server on this agent; one can
|
|
112
|
+
shadow or intercept the other.
|
|
113
|
+
maps_to: [OWASP-LLM03, OWASP-LLM06, MITRE-ATLAS-AML.T0110]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Policy-as-code: YAML rules evaluated deterministically over per-agent capabilities."""
|
|
2
|
+
|
|
3
|
+
from agentcapmap.policy.engine import evaluate, evaluate_rule
|
|
4
|
+
from agentcapmap.policy.loader import (
|
|
5
|
+
DEFAULT_POLICY_NAME,
|
|
6
|
+
Policy,
|
|
7
|
+
PolicyError,
|
|
8
|
+
Predicate,
|
|
9
|
+
Rule,
|
|
10
|
+
load_policy,
|
|
11
|
+
parse_policy,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_POLICY_NAME",
|
|
16
|
+
"Policy",
|
|
17
|
+
"PolicyError",
|
|
18
|
+
"Predicate",
|
|
19
|
+
"Rule",
|
|
20
|
+
"evaluate",
|
|
21
|
+
"evaluate_rule",
|
|
22
|
+
"load_policy",
|
|
23
|
+
"parse_policy",
|
|
24
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Deterministic rule evaluation (design rule 1: no LLM calls here, ever).
|
|
2
|
+
|
|
3
|
+
Every finding carries its evidence: the agent, servers, tools, the fields that matched
|
|
4
|
+
with their actual values, and the source phrases backing them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentcapmap.baseline import Baseline, diff_server
|
|
15
|
+
from agentcapmap.findings import (
|
|
16
|
+
Evaluation,
|
|
17
|
+
Finding,
|
|
18
|
+
FindingEvidence,
|
|
19
|
+
NotAssessed,
|
|
20
|
+
RuleInfo,
|
|
21
|
+
ServerLocation,
|
|
22
|
+
Severity,
|
|
23
|
+
SkippedRule,
|
|
24
|
+
)
|
|
25
|
+
from agentcapmap.graph import AgentView, ServerView, ToolView, build_views, capabilities
|
|
26
|
+
from agentcapmap.models import Inventory
|
|
27
|
+
from agentcapmap.policy.loader import Policy, Predicate, Rule
|
|
28
|
+
from agentcapmap.redact import get_logger
|
|
29
|
+
|
|
30
|
+
log = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fingerprint(rule: Rule, agent_id: str, servers: list[str], tools: list[str]) -> str:
|
|
34
|
+
material = json.dumps([rule.id, agent_id, sorted(servers), sorted(tools)], ensure_ascii=False)
|
|
35
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tool_evidence(predicate: Predicate, tv: ToolView) -> FindingEvidence:
|
|
39
|
+
phrases = {name: p for name in predicate.names if (p := tv.phrase(name))}
|
|
40
|
+
return FindingEvidence(
|
|
41
|
+
server=tv.server.name,
|
|
42
|
+
tool=tv.tool.name,
|
|
43
|
+
matched={name: tv.field(name) for name in predicate.names},
|
|
44
|
+
phrases=phrases,
|
|
45
|
+
unverified=[name for name in predicate.names if tv.unverified(name)],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _server_evidence(rule: Rule, sv: ServerView) -> FindingEvidence | None:
|
|
50
|
+
if rule.server_filter is None:
|
|
51
|
+
return None
|
|
52
|
+
predicate = next(p for p in rule.server_filter if p.matches(sv))
|
|
53
|
+
return FindingEvidence(
|
|
54
|
+
server=sv.name,
|
|
55
|
+
matched={name: sv.field(name) for name in predicate.names},
|
|
56
|
+
phrases={name: sv.phrase(name) for name in predicate.names},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _finding(
|
|
61
|
+
rule: Rule,
|
|
62
|
+
view: AgentView,
|
|
63
|
+
servers: list[ServerView],
|
|
64
|
+
tools: list[str],
|
|
65
|
+
evidence: list[FindingEvidence],
|
|
66
|
+
details: dict[str, Any] | None = None,
|
|
67
|
+
) -> Finding:
|
|
68
|
+
ordered = sorted(servers, key=lambda s: (s.server.location.line or 0, s.name))
|
|
69
|
+
names = sorted({s.name for s in servers})
|
|
70
|
+
unique_tools = sorted(set(tools))
|
|
71
|
+
return Finding(
|
|
72
|
+
rule_id=rule.id,
|
|
73
|
+
severity=rule.severity,
|
|
74
|
+
message=rule.message,
|
|
75
|
+
maps_to=list(rule.maps_to),
|
|
76
|
+
agent_id=view.agent.id,
|
|
77
|
+
config_path=view.agent.config_path,
|
|
78
|
+
location=ordered[0].server.location if ordered else None,
|
|
79
|
+
server_locations=[
|
|
80
|
+
ServerLocation(server=s.name, path=s.server.location.path, line=s.server.location.line)
|
|
81
|
+
for s in ordered
|
|
82
|
+
],
|
|
83
|
+
servers=names,
|
|
84
|
+
tools=unique_tools,
|
|
85
|
+
evidence=evidence,
|
|
86
|
+
details=details or {},
|
|
87
|
+
fingerprint=_fingerprint(rule, view.agent.id, names, unique_tools),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tag_rule(rule: Rule, view: AgentView, eligible: list[ServerView]) -> Iterator[Finding]:
|
|
92
|
+
names = {s.name for s in eligible}
|
|
93
|
+
candidates = [t for t in view.tools if t.server.name in names]
|
|
94
|
+
matched: list[tuple[Predicate, list[ToolView]]] = []
|
|
95
|
+
|
|
96
|
+
for predicate in rule.has_all:
|
|
97
|
+
hits = [t for t in candidates if predicate.matches(t)]
|
|
98
|
+
if not hits:
|
|
99
|
+
return
|
|
100
|
+
matched.append((predicate, hits))
|
|
101
|
+
if rule.has_any:
|
|
102
|
+
any_hits = [
|
|
103
|
+
(p, hits) for p in rule.has_any if (hits := [t for t in candidates if p.matches(t)])
|
|
104
|
+
]
|
|
105
|
+
if not any_hits:
|
|
106
|
+
return
|
|
107
|
+
matched.extend(any_hits)
|
|
108
|
+
|
|
109
|
+
evidence: list[FindingEvidence] = []
|
|
110
|
+
involved: dict[str, ServerView] = {}
|
|
111
|
+
tools: list[str] = []
|
|
112
|
+
for predicate, hits in matched:
|
|
113
|
+
for tv in sorted(hits, key=lambda t: t.ref):
|
|
114
|
+
evidence.append(_tool_evidence(predicate, tv))
|
|
115
|
+
involved[tv.server.name] = tv.server
|
|
116
|
+
tools.append(tv.ref)
|
|
117
|
+
for sv in sorted(involved.values(), key=lambda s: s.name):
|
|
118
|
+
if (server_ev := _server_evidence(rule, sv)) is not None:
|
|
119
|
+
evidence.append(server_ev)
|
|
120
|
+
yield _finding(rule, view, list(involved.values()), tools, evidence)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _server_rule(rule: Rule, view: AgentView, eligible: list[ServerView]) -> Iterator[Finding]:
|
|
124
|
+
for sv in eligible:
|
|
125
|
+
server_ev = _server_evidence(rule, sv)
|
|
126
|
+
yield _finding(rule, view, [sv], [], [server_ev] if server_ev else [])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _hash_rule(
|
|
130
|
+
rule: Rule, view: AgentView, eligible: list[ServerView], baseline: Baseline
|
|
131
|
+
) -> Iterator[Finding]:
|
|
132
|
+
for sv in eligible:
|
|
133
|
+
diff = diff_server(baseline, sv.server)
|
|
134
|
+
if not diff:
|
|
135
|
+
continue
|
|
136
|
+
summary = ", ".join(
|
|
137
|
+
f"{label}: {', '.join(items)}"
|
|
138
|
+
for label, items in (
|
|
139
|
+
("changed", diff.changed),
|
|
140
|
+
("added", diff.added),
|
|
141
|
+
("removed", diff.removed),
|
|
142
|
+
)
|
|
143
|
+
if items
|
|
144
|
+
)
|
|
145
|
+
evidence = [
|
|
146
|
+
FindingEvidence(
|
|
147
|
+
server=sv.name, matched={"definitions_changed": True}, phrases={"diff": summary}
|
|
148
|
+
)
|
|
149
|
+
]
|
|
150
|
+
tools = [f"{sv.name}/{n}" for n in (*diff.changed, *diff.added, *diff.removed)]
|
|
151
|
+
yield _finding(rule, view, [sv], tools, evidence, details=diff.model_dump())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _shadow_rule(rule: Rule, view: AgentView, eligible: list[ServerView]) -> Iterator[Finding]:
|
|
155
|
+
by_name: dict[str, list[ServerView]] = {}
|
|
156
|
+
for sv in eligible:
|
|
157
|
+
for tool_name in sorted(set(view.tool_names.get(sv.name, []))):
|
|
158
|
+
by_name.setdefault(tool_name, []).append(sv)
|
|
159
|
+
for tool_name, servers in sorted(by_name.items()):
|
|
160
|
+
if len(servers) < 2:
|
|
161
|
+
continue
|
|
162
|
+
evidence = [
|
|
163
|
+
FindingEvidence(
|
|
164
|
+
server=sv.name,
|
|
165
|
+
tool=tool_name,
|
|
166
|
+
matched={"tool_name_shadowed": True},
|
|
167
|
+
phrases={"servers": ", ".join(s.name for s in servers)},
|
|
168
|
+
)
|
|
169
|
+
for sv in servers
|
|
170
|
+
]
|
|
171
|
+
tools = [f"{sv.name}/{tool_name}" for sv in servers]
|
|
172
|
+
yield _finding(rule, view, servers, tools, evidence, details={"tool_name": tool_name})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def evaluate_rule(rule: Rule, view: AgentView, baseline: Baseline | None) -> list[Finding]:
|
|
176
|
+
filters = rule.server_filter
|
|
177
|
+
eligible = [s for s in view.servers if filters is None or any(p.matches(s) for p in filters)]
|
|
178
|
+
if rule.hash_changed:
|
|
179
|
+
return list(_hash_rule(rule, view, eligible, baseline)) if baseline else []
|
|
180
|
+
if rule.shadowed:
|
|
181
|
+
return list(_shadow_rule(rule, view, eligible))
|
|
182
|
+
if rule.tag_based:
|
|
183
|
+
return list(_tag_rule(rule, view, eligible))
|
|
184
|
+
return list(_server_rule(rule, view, eligible))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def evaluate(inventory: Inventory, policy: Policy, baseline: Baseline | None = None) -> Evaluation:
|
|
188
|
+
views = build_views(inventory, list(policy.trusted_sources))
|
|
189
|
+
findings: list[Finding] = []
|
|
190
|
+
skipped: list[SkippedRule] = []
|
|
191
|
+
for rule in policy.rules:
|
|
192
|
+
if not rule.enabled:
|
|
193
|
+
skipped.append(SkippedRule(rule_id=rule.id, reason="disabled in policy"))
|
|
194
|
+
continue
|
|
195
|
+
if rule.hash_changed and baseline is None:
|
|
196
|
+
skipped.append(SkippedRule(rule_id=rule.id, reason="no baseline provided (--baseline)"))
|
|
197
|
+
continue
|
|
198
|
+
for view in views:
|
|
199
|
+
findings.extend(evaluate_rule(rule, view, baseline))
|
|
200
|
+
|
|
201
|
+
findings.sort(key=lambda f: (-f.severity.rank, f.rule_id, f.agent_id, f.servers, f.tools))
|
|
202
|
+
not_assessed: list[NotAssessed] = [na for v in views for na in v.not_assessed]
|
|
203
|
+
if not_assessed:
|
|
204
|
+
log.warning(
|
|
205
|
+
"%d server(s) or tool(s) were not assessed; findings may be incomplete "
|
|
206
|
+
"(see 'not_assessed' in the output)",
|
|
207
|
+
len(not_assessed),
|
|
208
|
+
)
|
|
209
|
+
summary = {s.value: sum(1 for f in findings if f.severity is s) for s in Severity}
|
|
210
|
+
summary["not_assessed"] = len(not_assessed)
|
|
211
|
+
return Evaluation(
|
|
212
|
+
policy=policy.info,
|
|
213
|
+
rules=[
|
|
214
|
+
RuleInfo(
|
|
215
|
+
id=r.id,
|
|
216
|
+
severity=r.severity,
|
|
217
|
+
message=r.message,
|
|
218
|
+
maps_to=list(r.maps_to),
|
|
219
|
+
enabled=r.enabled,
|
|
220
|
+
)
|
|
221
|
+
for r in policy.rules
|
|
222
|
+
],
|
|
223
|
+
baseline_used=baseline is not None,
|
|
224
|
+
agents=[capabilities(v) for v in views],
|
|
225
|
+
findings=findings,
|
|
226
|
+
not_assessed=not_assessed,
|
|
227
|
+
skipped_rules=skipped,
|
|
228
|
+
summary=summary,
|
|
229
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Load and validate policy files.
|
|
2
|
+
|
|
3
|
+
Policy files may come from a repository being scanned, so they are untrusted input:
|
|
4
|
+
- size-capped before parsing;
|
|
5
|
+
- parsed with PyYAML's safe loader only (no arbitrary object construction);
|
|
6
|
+
- anchors and aliases rejected (no "billion laughs" expansion, no merge-key tricks);
|
|
7
|
+
- duplicate keys rejected (YAML would otherwise silently keep the last one);
|
|
8
|
+
- validated against schemas/policy.schema.json, with unknown fields rejected;
|
|
9
|
+
- checked for duplicate rule ids and contradictory condition combinations.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from jsonschema import Draft202012Validator
|
|
23
|
+
from jsonschema.exceptions import best_match
|
|
24
|
+
|
|
25
|
+
from agentcapmap.findings import PolicyInfo, Severity
|
|
26
|
+
from agentcapmap.taxonomy import TAXONOMY_VERSION, data_file
|
|
27
|
+
|
|
28
|
+
DEFAULT_POLICY_NAME = "builtin:default.yaml"
|
|
29
|
+
# Policies are small (the default is ~4 KB); a tight cap also bounds parser work.
|
|
30
|
+
_MAX_POLICY_BYTES = 256 * 1024
|
|
31
|
+
_MAX_FLOW_DEPTH = 32
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PolicyError(ValueError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Predicate:
|
|
40
|
+
"""Field -> allowed values. Every field must match on the same tool or server."""
|
|
41
|
+
|
|
42
|
+
fields: tuple[tuple[str, frozenset[str | bool]], ...]
|
|
43
|
+
|
|
44
|
+
def matches(self, view: Any) -> bool:
|
|
45
|
+
return all(view.field(name) in allowed for name, allowed in self.fields)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def names(self) -> tuple[str, ...]:
|
|
49
|
+
return tuple(name for name, _ in self.fields)
|
|
50
|
+
|
|
51
|
+
def describe(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
name: sorted(allowed, key=str) if len(allowed) > 1 else next(iter(allowed))
|
|
54
|
+
for name, allowed in self.fields
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Rule:
|
|
60
|
+
id: str
|
|
61
|
+
severity: Severity
|
|
62
|
+
message: str
|
|
63
|
+
maps_to: tuple[str, ...]
|
|
64
|
+
enabled: bool
|
|
65
|
+
has_all: tuple[Predicate, ...]
|
|
66
|
+
has_any: tuple[Predicate, ...]
|
|
67
|
+
server_filter: tuple[Predicate, ...] | None
|
|
68
|
+
hash_changed: bool
|
|
69
|
+
shadowed: bool
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def tag_based(self) -> bool:
|
|
73
|
+
return bool(self.has_all or self.has_any)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Policy:
|
|
78
|
+
rules: tuple[Rule, ...]
|
|
79
|
+
trusted_sources: tuple[str, ...]
|
|
80
|
+
info: PolicyInfo
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _StrictSafeLoader(yaml.SafeLoader):
|
|
84
|
+
"""SafeLoader that refuses duplicate mapping keys."""
|
|
85
|
+
|
|
86
|
+
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
87
|
+
seen: set[Any] = set()
|
|
88
|
+
for key_node, _value in node.value:
|
|
89
|
+
key = self.construct_object(key_node, deep=deep)
|
|
90
|
+
try:
|
|
91
|
+
duplicate = key in seen
|
|
92
|
+
except TypeError:
|
|
93
|
+
raise PolicyError(
|
|
94
|
+
f"unsupported mapping key at line {key_node.start_mark.line + 1}"
|
|
95
|
+
) from None
|
|
96
|
+
if duplicate:
|
|
97
|
+
raise PolicyError(f"duplicate key {key!r} at line {key_node.start_mark.line + 1}")
|
|
98
|
+
seen.add(key)
|
|
99
|
+
return super().construct_mapping(node, deep=deep)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_flow_depth(text: str) -> None:
|
|
103
|
+
"""Reject deep [ / { nesting before PyYAML sees it.
|
|
104
|
+
|
|
105
|
+
PyYAML's parser is super-linear in flow-collection depth (8,000 nested brackets take
|
|
106
|
+
~50 s), so a small hostile file could stall a scan. Brackets inside quoted strings
|
|
107
|
+
are counted too: that is conservative (it can only reject, never let through), and
|
|
108
|
+
no sensible policy comes close to the limit.
|
|
109
|
+
"""
|
|
110
|
+
depth = 0
|
|
111
|
+
for index, char in enumerate(text):
|
|
112
|
+
if char in "[{":
|
|
113
|
+
depth += 1
|
|
114
|
+
if depth > _MAX_FLOW_DEPTH:
|
|
115
|
+
line = text.count("\n", 0, index) + 1
|
|
116
|
+
raise PolicyError(f"YAML nested more than {_MAX_FLOW_DEPTH} levels (line {line})")
|
|
117
|
+
elif char in "]}" and depth:
|
|
118
|
+
depth -= 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_yaml(text: str) -> Any:
|
|
122
|
+
_check_flow_depth(text)
|
|
123
|
+
try:
|
|
124
|
+
for event in yaml.parse(text, Loader=yaml.SafeLoader):
|
|
125
|
+
if isinstance(event, yaml.AliasEvent) or getattr(event, "anchor", None):
|
|
126
|
+
line = event.start_mark.line + 1 if event.start_mark else "?"
|
|
127
|
+
raise PolicyError(f"YAML anchors and aliases are not allowed (line {line})")
|
|
128
|
+
loader = _StrictSafeLoader(text)
|
|
129
|
+
try:
|
|
130
|
+
return loader.get_single_data()
|
|
131
|
+
finally:
|
|
132
|
+
loader.dispose()
|
|
133
|
+
except yaml.YAMLError as exc:
|
|
134
|
+
mark = getattr(exc, "problem_mark", None)
|
|
135
|
+
where = f" at line {mark.line + 1}" if mark is not None else ""
|
|
136
|
+
raise PolicyError(f"invalid YAML{where}") from None
|
|
137
|
+
except RecursionError:
|
|
138
|
+
raise PolicyError("YAML nested too deeply") from None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@functools.cache
|
|
142
|
+
def _validator() -> Draft202012Validator:
|
|
143
|
+
schema = json.loads(data_file("schemas", "policy.schema.json"))
|
|
144
|
+
Draft202012Validator.check_schema(schema)
|
|
145
|
+
return Draft202012Validator(schema)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _predicates(raw: Any) -> tuple[Predicate, ...]:
|
|
149
|
+
if raw is None:
|
|
150
|
+
return ()
|
|
151
|
+
items = raw if isinstance(raw, list) else [raw]
|
|
152
|
+
return tuple(
|
|
153
|
+
Predicate(
|
|
154
|
+
fields=tuple(
|
|
155
|
+
(name, frozenset(value if isinstance(value, list) else [value]))
|
|
156
|
+
for name, value in sorted(item.items())
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
for item in items
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_rule(raw: dict[str, Any]) -> Rule:
|
|
164
|
+
rule = Rule(
|
|
165
|
+
id=raw["id"],
|
|
166
|
+
severity=Severity(raw["severity"]),
|
|
167
|
+
message=" ".join(raw["message"].split()),
|
|
168
|
+
maps_to=tuple(raw.get("maps_to", [])),
|
|
169
|
+
enabled=raw.get("enabled", True),
|
|
170
|
+
has_all=_predicates(raw.get("when_agent_has_all")),
|
|
171
|
+
has_any=_predicates(raw.get("when_agent_has_any")),
|
|
172
|
+
server_filter=_predicates(raw["when_server"]) if "when_server" in raw else None,
|
|
173
|
+
hash_changed=raw.get("when_hash_changed", False),
|
|
174
|
+
shadowed=raw.get("when_tool_name_shadowed", False),
|
|
175
|
+
)
|
|
176
|
+
special = [flag for flag in (rule.hash_changed, rule.shadowed) if flag]
|
|
177
|
+
if len(special) > 1 or (special and rule.tag_based):
|
|
178
|
+
raise PolicyError(
|
|
179
|
+
f"rule {rule.id}: when_hash_changed / when_tool_name_shadowed cannot be combined "
|
|
180
|
+
"with each other or with when_agent_has_* conditions"
|
|
181
|
+
)
|
|
182
|
+
return rule
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_policy(text: str, source: str) -> Policy:
|
|
186
|
+
data = _parse_yaml(text)
|
|
187
|
+
error = best_match(_validator().iter_errors(data))
|
|
188
|
+
if error is not None:
|
|
189
|
+
where = "/".join(str(p) for p in error.absolute_path) or "(root)"
|
|
190
|
+
raise PolicyError(f"{source}: invalid policy at {where}: {error.message}")
|
|
191
|
+
if data["taxonomy_version"] != TAXONOMY_VERSION:
|
|
192
|
+
raise PolicyError(f"{source}: policy targets taxonomy {data['taxonomy_version']!r}")
|
|
193
|
+
|
|
194
|
+
rules = tuple(_build_rule(raw) for raw in data["rules"])
|
|
195
|
+
ids = [r.id for r in rules]
|
|
196
|
+
if len(ids) != len(set(ids)):
|
|
197
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
198
|
+
raise PolicyError(f"{source}: duplicate rule id(s): {', '.join(dupes)}")
|
|
199
|
+
return Policy(
|
|
200
|
+
rules=rules,
|
|
201
|
+
trusted_sources=tuple(data.get("trusted_sources", [])),
|
|
202
|
+
info=PolicyInfo(
|
|
203
|
+
source=source,
|
|
204
|
+
version=data["version"],
|
|
205
|
+
taxonomy_version=data["taxonomy_version"],
|
|
206
|
+
sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def load_policy(path: Path | None = None) -> Policy:
|
|
212
|
+
"""Load a policy file, or the built-in default policy when `path` is None."""
|
|
213
|
+
if path is None:
|
|
214
|
+
return parse_policy(data_file("policies", "default.yaml"), DEFAULT_POLICY_NAME)
|
|
215
|
+
try:
|
|
216
|
+
if path.stat().st_size > _MAX_POLICY_BYTES:
|
|
217
|
+
raise PolicyError(f"{path}: policy file larger than {_MAX_POLICY_BYTES} bytes")
|
|
218
|
+
text = path.read_text(encoding="utf-8")
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
raise PolicyError(f"cannot read policy {path}: {exc.strerror}") from None
|
|
221
|
+
except UnicodeDecodeError:
|
|
222
|
+
raise PolicyError(f"{path}: policy is not UTF-8 text") from None
|
|
223
|
+
return parse_policy(text, path.as_posix())
|
agentcapmap/py.typed
ADDED
|
File without changes
|