toolgraph 0.0.1__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.
- toolgraph/__init__.py +3 -0
- toolgraph/artifact_safety.py +97 -0
- toolgraph/artifacts.py +122 -0
- toolgraph/cli.py +951 -0
- toolgraph/config.py +106 -0
- toolgraph/control_plan.py +494 -0
- toolgraph/crawler/__init__.py +0 -0
- toolgraph/crawler/client.py +120 -0
- toolgraph/crawler/crawl.py +103 -0
- toolgraph/crawler/transports.py +60 -0
- toolgraph/graph/__init__.py +0 -0
- toolgraph/graph/driver.py +275 -0
- toolgraph/graph/loader.py +280 -0
- toolgraph/graph/queries.py +984 -0
- toolgraph/graph/schema.py +166 -0
- toolgraph/graph/selector.py +423 -0
- toolgraph/graph/store.py +57 -0
- toolgraph/manifest/__init__.py +0 -0
- toolgraph/manifest/ingest.py +542 -0
- toolgraph/manifest/parser.py +26 -0
- toolgraph/models.py +192 -0
- toolgraph/policy_bundle.py +212 -0
- toolgraph/policy_review.py +97 -0
- toolgraph/preflight.py +77 -0
- toolgraph/quickstart/governance.yaml +28 -0
- toolgraph/quickstart/policy_gateway_server.py +31 -0
- toolgraph/quickstart/servers.yaml +5 -0
- toolgraph/redaction.py +82 -0
- toolgraph/review_candidates.py +579 -0
- toolgraph/server/__init__.py +0 -0
- toolgraph/server/app.py +306 -0
- toolgraph/uris.py +64 -0
- toolgraph/yaml_loader.py +68 -0
- toolgraph-0.0.1.dist-info/METADATA +674 -0
- toolgraph-0.0.1.dist-info/RECORD +38 -0
- toolgraph-0.0.1.dist-info/WHEEL +4 -0
- toolgraph-0.0.1.dist-info/entry_points.txt +2 -0
- toolgraph-0.0.1.dist-info/licenses/LICENSE +191 -0
toolgraph/server/app.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""toolgraph as an MCP server: agents query the registry over the same protocol
|
|
2
|
+
it crawls. Thin wrappers over graph.queries — all logic lives there.
|
|
3
|
+
|
|
4
|
+
Every response carries ``graph_generation`` (ADR-0004) so callers can cache
|
|
5
|
+
graph-derived features and pin telemetry to a replayable graph state. The
|
|
6
|
+
stamp lives HERE, not in the query layer — CLI output stays unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
17
|
+
from mcp.types import CallToolResult, ContentBlock, TextContent, ToolAnnotations
|
|
18
|
+
|
|
19
|
+
from toolgraph.graph import queries, selector
|
|
20
|
+
from toolgraph.graph.driver import is_backend_unavailable
|
|
21
|
+
from toolgraph.redaction import redact_text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_BACKEND_UNAVAILABLE_MESSAGE = (
|
|
27
|
+
"Toolgraph backend is temporarily unavailable; retry later."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _ToolgraphMCP(FastMCP):
|
|
32
|
+
"""FastMCP server with a typed envelope for backend availability only."""
|
|
33
|
+
|
|
34
|
+
def read_only_tool(self, name: str | None = None, **kwargs: Any):
|
|
35
|
+
"""Register a pure-read graph tool: safe to retry, no added side effect.
|
|
36
|
+
|
|
37
|
+
Each tool gets its own annotations object, so a later per-tool edit
|
|
38
|
+
cannot leak across every registration.
|
|
39
|
+
"""
|
|
40
|
+
if "annotations" in kwargs:
|
|
41
|
+
raise TypeError("read_only_tool owns 'annotations'; use tool() instead")
|
|
42
|
+
|
|
43
|
+
def decorator(fn):
|
|
44
|
+
return self.tool(
|
|
45
|
+
name=name,
|
|
46
|
+
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
|
47
|
+
**kwargs,
|
|
48
|
+
)(fn)
|
|
49
|
+
|
|
50
|
+
return decorator
|
|
51
|
+
|
|
52
|
+
async def _is_retryable(self, name: str) -> bool:
|
|
53
|
+
"""Whether a failed call to *name* is safe to repeat.
|
|
54
|
+
|
|
55
|
+
Read from the annotations the server actually advertises, so there is
|
|
56
|
+
exactly one source of truth. A parallel registry could disagree with
|
|
57
|
+
the live registration — FastMCP keeps the *first* tool on a duplicate
|
|
58
|
+
name, so a write tool could otherwise inherit a later read-only claim.
|
|
59
|
+
|
|
60
|
+
Every failure path answers "no": an unknown tool, an unannotated tool,
|
|
61
|
+
and a lookup that raises. This runs while an outage is already being
|
|
62
|
+
reported, so a lookup fault must not escape and replace the typed
|
|
63
|
+
envelope with an unstructured error — the exact failure this envelope
|
|
64
|
+
exists to prevent. Under-claiming retry safety is the safe direction,
|
|
65
|
+
but it is still a degraded answer, so log it rather than swallow it.
|
|
66
|
+
|
|
67
|
+
``BaseException`` is deliberately not caught: cancellation and shutdown
|
|
68
|
+
must propagate rather than be absorbed into a response.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
for tool in await self.list_tools():
|
|
72
|
+
if tool.name == name:
|
|
73
|
+
return bool(tool.annotations and tool.annotations.readOnlyHint)
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.warning(
|
|
76
|
+
"could not read retry safety for tool %r; reporting not retryable",
|
|
77
|
+
name,
|
|
78
|
+
exc_info=True,
|
|
79
|
+
)
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def call_tool(
|
|
83
|
+
self, name: str, arguments: dict[str, Any]
|
|
84
|
+
) -> Sequence[ContentBlock] | dict[str, Any] | CallToolResult:
|
|
85
|
+
try:
|
|
86
|
+
return await super().call_tool(name, arguments)
|
|
87
|
+
except ToolError as exc:
|
|
88
|
+
if not is_backend_unavailable(exc):
|
|
89
|
+
# The seam types every outage it covers, so reaching here means
|
|
90
|
+
# either a contract error or a driver exception that never
|
|
91
|
+
# passed the seam. FastMCP puts this message on the wire
|
|
92
|
+
# verbatim, and driver text can carry a connection URI, so it
|
|
93
|
+
# is scrubbed before it leaves. Ordinary contract text is
|
|
94
|
+
# unaffected; the cause is kept for server-side logs.
|
|
95
|
+
raise ToolError(redact_text(exc)) from exc
|
|
96
|
+
payload: dict[str, Any] = {
|
|
97
|
+
"error_kind": "backend_unavailable",
|
|
98
|
+
"retryable": await self._is_retryable(name),
|
|
99
|
+
"message": _BACKEND_UNAVAILABLE_MESSAGE,
|
|
100
|
+
}
|
|
101
|
+
return CallToolResult(
|
|
102
|
+
content=[TextContent(type="text", text=_BACKEND_UNAVAILABLE_MESSAGE)],
|
|
103
|
+
structuredContent=payload,
|
|
104
|
+
isError=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
mcp = _ToolgraphMCP("toolgraph")
|
|
109
|
+
|
|
110
|
+
_with_generation = queries.with_graph_state
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@mcp.read_only_tool()
|
|
114
|
+
def check_access(agent: str, tool: str) -> dict:
|
|
115
|
+
"""Can `agent` call `tool`, and does the call path cross a DENY policy?
|
|
116
|
+
|
|
117
|
+
Returns a verdict (AGENT_NOT_FOUND | TOOL_NOT_FOUND | AMBIGUOUS_TOOL |
|
|
118
|
+
NOT_GRANTED | DENY | ALLOW) with the exact resource/policy evidence path.
|
|
119
|
+
Every response carries ``agent_found: bool`` and ``found`` for the tool
|
|
120
|
+
(True/False when resolution ran; null on AGENT_NOT_FOUND). On DENY, the
|
|
121
|
+
response also carries ``all_authorized: bool`` — True when every
|
|
122
|
+
deny_evidence row is an operator-declared exception.
|
|
123
|
+
"""
|
|
124
|
+
return _with_generation(lambda: queries.check_access(agent, tool))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.read_only_tool()
|
|
128
|
+
def unsafe_callable_tools(agent: str, include_authorized: bool = False) -> dict:
|
|
129
|
+
"""Tools `agent` is allowed to call that reach a DENY policy.
|
|
130
|
+
|
|
131
|
+
Returns an object so the MCP structured-content shape is stable:
|
|
132
|
+
``{"agent", "agent_found", "count", "tools": [...]}``.
|
|
133
|
+
|
|
134
|
+
Defaults to ``classification='violation'`` rows only — matching the CLI
|
|
135
|
+
semantics so MCP consumers don't see authorized_but_governed rows mixed
|
|
136
|
+
in by default. Pass ``include_authorized=True`` for the full list.
|
|
137
|
+
|
|
138
|
+
``agent_found=False`` distinguishes a typo'd agent (returns empty) from
|
|
139
|
+
an agent with zero unsafe tools (also returns empty).
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def fetch() -> dict:
|
|
143
|
+
if not queries.agent_exists(agent):
|
|
144
|
+
return {"agent": agent, "agent_found": False, "count": 0, "tools": []}
|
|
145
|
+
tools = queries.unsafe_callable_tools(agent)
|
|
146
|
+
if not include_authorized:
|
|
147
|
+
tools = [t for t in tools if t.get("classification") == "violation"]
|
|
148
|
+
return {
|
|
149
|
+
"agent": agent,
|
|
150
|
+
"agent_found": True,
|
|
151
|
+
"count": len(tools),
|
|
152
|
+
"tools": tools,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return _with_generation(fetch)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@mcp.read_only_tool()
|
|
159
|
+
def blast_radius(node: str) -> dict:
|
|
160
|
+
"""Agents/tools affected if `node` (a resource URI or a policy id) changes."""
|
|
161
|
+
return _with_generation(lambda: queries.blast_radius(node))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.read_only_tool()
|
|
165
|
+
def unmapped_tools(only_granted: bool = True) -> dict:
|
|
166
|
+
"""Granted tools (or all tools if `only_granted=False`) with no authored
|
|
167
|
+
READS/WRITES — data-flow effects unclassified."""
|
|
168
|
+
|
|
169
|
+
def fetch() -> dict:
|
|
170
|
+
rows = queries.unmapped_tools(only_granted=only_granted)
|
|
171
|
+
return {"count": len(rows), "tools": rows}
|
|
172
|
+
|
|
173
|
+
return _with_generation(fetch)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@mcp.read_only_tool()
|
|
177
|
+
def orphan_policies() -> dict:
|
|
178
|
+
"""Policies no agent currently reaches — likely obsolete or over-narrow."""
|
|
179
|
+
|
|
180
|
+
def fetch() -> dict:
|
|
181
|
+
rows = queries.orphan_policies()
|
|
182
|
+
return {"count": len(rows), "policies": rows}
|
|
183
|
+
|
|
184
|
+
return _with_generation(fetch)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@mcp.read_only_tool()
|
|
188
|
+
def unbacked_edges(include_grants: bool = False) -> dict:
|
|
189
|
+
"""Authored edges with no usable evidence pointer.
|
|
190
|
+
|
|
191
|
+
Default scope is READS/WRITES/GOVERNED_BY — load-bearing claims about the
|
|
192
|
+
world. Pass ``include_grants=True`` to also surface CAN_CALL grants
|
|
193
|
+
(intent declarations, usually noise unless your policy requires every
|
|
194
|
+
grant to cite a change-request / runbook).
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def fetch() -> dict:
|
|
198
|
+
rows = queries.unbacked_edges(include_grants=include_grants)
|
|
199
|
+
return {"count": len(rows), "edges": rows}
|
|
200
|
+
|
|
201
|
+
return _with_generation(fetch)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.read_only_tool()
|
|
205
|
+
def drifted_tools() -> dict:
|
|
206
|
+
"""Tools with authored governance but no live EXPOSES edge — re-ingest needed."""
|
|
207
|
+
|
|
208
|
+
def fetch() -> dict:
|
|
209
|
+
rows = queries.drifted_tools()
|
|
210
|
+
return {"count": len(rows), "tools": rows}
|
|
211
|
+
|
|
212
|
+
return _with_generation(fetch)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@mcp.read_only_tool()
|
|
216
|
+
def destructive_unsafeguarded() -> dict:
|
|
217
|
+
"""Tools hinting ``destructiveHint: true`` with no GOVERNED_BY path —
|
|
218
|
+
neither directly on the tool nor via any resource it reads/writes.
|
|
219
|
+
|
|
220
|
+
The hint is the server's self-claim (``crawled``/``medium``, ADR-0006):
|
|
221
|
+
these rows are an authoring priority queue, not violations. Annotations
|
|
222
|
+
never auto-create edges; the operator authors the policy binding.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def fetch() -> dict:
|
|
226
|
+
rows = queries.destructive_unsafeguarded()
|
|
227
|
+
return {"count": len(rows), "tools": rows}
|
|
228
|
+
|
|
229
|
+
return _with_generation(fetch)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.read_only_tool()
|
|
233
|
+
def annotation_contradictions() -> dict:
|
|
234
|
+
"""Authored WRITES edges on tools hinting ``readOnlyHint: true``.
|
|
235
|
+
|
|
236
|
+
One side is wrong — the server's self-claim or the operator's assertion.
|
|
237
|
+
Each row cites both provenances so the operator can chase the stronger
|
|
238
|
+
evidence; the analyzer never picks a winner (ADR-0006).
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def fetch() -> dict:
|
|
242
|
+
rows = queries.annotation_contradictions()
|
|
243
|
+
return {"count": len(rows), "contradictions": rows}
|
|
244
|
+
|
|
245
|
+
return _with_generation(fetch)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mcp.read_only_tool()
|
|
249
|
+
def audit_report(include_grants: bool = False) -> dict:
|
|
250
|
+
"""Whole-graph operational audit summary.
|
|
251
|
+
|
|
252
|
+
Aggregates unsafe DENY violations, operator-authorized DENY paths, drift,
|
|
253
|
+
unmapped tools, unbacked evidence, orphan policies, destructive annotation
|
|
254
|
+
gaps, and annotation contradictions. ``status`` is ``fail`` only for
|
|
255
|
+
blocking findings (unsafe violations or drifted governed tools), ``warn``
|
|
256
|
+
for advisory findings, and ``pass`` when the graph is clean.
|
|
257
|
+
"""
|
|
258
|
+
return _with_generation(lambda: queries.audit_report(include_grants=include_grants))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.read_only_tool()
|
|
262
|
+
def rank_features(agent: str, candidates: list[str]) -> dict:
|
|
263
|
+
"""Batch selection features for every candidate, in input order (ADR-0005).
|
|
264
|
+
|
|
265
|
+
Facts only — resolution (found/ambiguous/tool_key), grant, verdict, DENY
|
|
266
|
+
classification + evidence paths, drift, mapping, evidence coverage, the
|
|
267
|
+
four annotation self-claims — plus the rule-based ``risk_score`` from the
|
|
268
|
+
published fixed table. No relevance, no learning. ``agent_found: false``
|
|
269
|
+
means selection should abort: context-construction error, not an empty
|
|
270
|
+
result. Cache per ``graph_generation``.
|
|
271
|
+
"""
|
|
272
|
+
return _with_generation(lambda: selector.rank_features(agent, candidates))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@mcp.read_only_tool()
|
|
276
|
+
def eligible_tools(
|
|
277
|
+
agent: str, candidates: list[str], profile: str = selector.DEFAULT_PROFILE
|
|
278
|
+
) -> dict:
|
|
279
|
+
"""Hard-filter candidates with reject reasons + policy-evidence paths.
|
|
280
|
+
|
|
281
|
+
``profile`` is a named rule set — strict (production default) / review /
|
|
282
|
+
explore — not a learned threshold. ``eligible`` holds resolved tool keys
|
|
283
|
+
in candidate input order; each rejected row carries the input ref, the
|
|
284
|
+
winning reason, and for DENY reasons the graph paths that prove it. A
|
|
285
|
+
learned consumer may rerank ``eligible`` but may never resurrect a
|
|
286
|
+
rejected row (ADR-0005).
|
|
287
|
+
"""
|
|
288
|
+
return _with_generation(
|
|
289
|
+
lambda: selector.eligible_tools(agent, candidates, profile=profile)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@mcp.read_only_tool()
|
|
294
|
+
def selection_explain(
|
|
295
|
+
agent: str, tool: str, profile: str = selector.DEFAULT_PROFILE
|
|
296
|
+
) -> dict:
|
|
297
|
+
"""Compact human-readable reasons why `tool` is eligible/rejected for
|
|
298
|
+
`agent` under `profile` — a view over the same facts ``rank_features``
|
|
299
|
+
returns, for operators and end users."""
|
|
300
|
+
return _with_generation(
|
|
301
|
+
lambda: selector.selection_explain(agent, tool, profile=profile)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
mcp.run()
|
toolgraph/uris.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Resource-URI canonicalization, shared by the crawler, manifest, and queries.
|
|
2
|
+
|
|
3
|
+
MCP returns resource URIs as pydantic ``AnyUrl``, which normalizes (adds a
|
|
4
|
+
trailing slash to authority-only URLs, lowercases scheme/host, etc.). The
|
|
5
|
+
manifest is authored as raw strings. Both sides must canonicalize identically
|
|
6
|
+
or a governed resource and a crawled resource become two different graph nodes —
|
|
7
|
+
which would let a deny-governed resource slip past as ALLOW. Run every resource
|
|
8
|
+
URI through ``normalize_resource_uri`` at both write and read boundaries.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
from pydantic import AnyUrl, ValidationError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResourceUriNormalizationWarning(UserWarning):
|
|
20
|
+
"""A URL-shaped resource identifier could not be canonicalized."""
|
|
21
|
+
|
|
22
|
+
# A URI scheme (RFC 3986): scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"
|
|
23
|
+
_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
|
|
24
|
+
_TOOL_REF_PREFIX = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*::")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_resource_uri(uri: str) -> str:
|
|
28
|
+
"""Canonicalize a resource URI the same way MCP's AnyUrl does.
|
|
29
|
+
|
|
30
|
+
Non-URL strings (custom identifiers AnyUrl can't parse) are returned
|
|
31
|
+
unchanged, so they still match consistently across write and read.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
return str(AnyUrl(uri))
|
|
35
|
+
except (ValidationError, ValueError):
|
|
36
|
+
if "://" in uri:
|
|
37
|
+
# A string that *looks* like a URL but fails to parse is exactly
|
|
38
|
+
# the identity-split hazard this module's docstring describes: if
|
|
39
|
+
# the other boundary's pydantic parses it, a deny-governed
|
|
40
|
+
# resource and its crawled twin become two different nodes. Custom
|
|
41
|
+
# non-URL identifiers (no "://") fall through silently by design.
|
|
42
|
+
warnings.warn(
|
|
43
|
+
"resource URI could not be canonicalized and is used verbatim;"
|
|
44
|
+
" if the crawler and manifest disagree about this URI, its"
|
|
45
|
+
" governance may not attach",
|
|
46
|
+
ResourceUriNormalizationWarning,
|
|
47
|
+
stacklevel=2,
|
|
48
|
+
)
|
|
49
|
+
return uri
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_resource_ref(ref: str) -> bool:
|
|
53
|
+
"""Distinguish a resource URI from a tool ref / policy id.
|
|
54
|
+
|
|
55
|
+
A resource URI carries a URI scheme (``scheme:`` — covers ``file:///x``,
|
|
56
|
+
``memory:foo``, ``urn:secret``, ``file:/tmp/a``), whereas a tool ref is the
|
|
57
|
+
``server::tool`` convention or a bare name. Only a leading ``server::``
|
|
58
|
+
prefix suppresses URI detection; IPv6 URI authorities such as
|
|
59
|
+
``http://[::1]/x`` also contain ``::`` and must still be treated as
|
|
60
|
+
resources.
|
|
61
|
+
"""
|
|
62
|
+
if _TOOL_REF_PREFIX.match(ref):
|
|
63
|
+
return False
|
|
64
|
+
return bool(_SCHEME.match(ref))
|
toolgraph/yaml_loader.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Fail-closed YAML loading for operator-authored configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UniqueKeySafeLoader(yaml.SafeLoader):
|
|
12
|
+
"""SafeLoader variant that rejects duplicate mapping keys at any depth."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _construct_mapping(
|
|
16
|
+
loader: UniqueKeySafeLoader, node: yaml.MappingNode, deep: bool = False
|
|
17
|
+
) -> dict[Any, Any]:
|
|
18
|
+
loader.flatten_mapping(node)
|
|
19
|
+
mapping: dict[Any, Any] = {}
|
|
20
|
+
for key_node, value_node in node.value:
|
|
21
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
22
|
+
try:
|
|
23
|
+
duplicate = key in mapping
|
|
24
|
+
except TypeError as exc:
|
|
25
|
+
raise yaml.constructor.ConstructorError(
|
|
26
|
+
"while constructing a mapping",
|
|
27
|
+
node.start_mark,
|
|
28
|
+
"found an unhashable key",
|
|
29
|
+
key_node.start_mark,
|
|
30
|
+
) from exc
|
|
31
|
+
if duplicate:
|
|
32
|
+
raise yaml.constructor.ConstructorError(
|
|
33
|
+
"while constructing a mapping",
|
|
34
|
+
node.start_mark,
|
|
35
|
+
f"found duplicate key {key!r}",
|
|
36
|
+
key_node.start_mark,
|
|
37
|
+
)
|
|
38
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
39
|
+
return mapping
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
UniqueKeySafeLoader.add_constructor(
|
|
43
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
MAX_YAML_BYTES = 5_000_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_yaml_mapping(path: Path) -> dict[str, Any]:
|
|
51
|
+
"""Load a size-bounded YAML mapping; reject duplicate keys, non-mapping roots."""
|
|
52
|
+
# Bounded read from one open descriptor (stat-then-read is a TOCTOU).
|
|
53
|
+
with path.open("rb") as handle:
|
|
54
|
+
raw = handle.read(MAX_YAML_BYTES + 1)
|
|
55
|
+
if len(raw) > MAX_YAML_BYTES:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"refusing to load {path}: exceeds the {MAX_YAML_BYTES}-byte limit"
|
|
58
|
+
)
|
|
59
|
+
loader = UniqueKeySafeLoader(raw.decode("utf-8"))
|
|
60
|
+
try:
|
|
61
|
+
value = loader.get_single_data()
|
|
62
|
+
finally:
|
|
63
|
+
loader.dispose()
|
|
64
|
+
if value is None:
|
|
65
|
+
return {}
|
|
66
|
+
if not isinstance(value, dict):
|
|
67
|
+
raise ValueError(f"expected a YAML mapping at document root: {path}")
|
|
68
|
+
return value
|