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,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