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
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
"""Governance and audit queries — the product.
|
|
2
|
+
|
|
3
|
+
Each returns explainable structure (the path + evidence), not a bare boolean.
|
|
4
|
+
Reachability spans Agent -> Tool -> Resource -> Policy across four node types,
|
|
5
|
+
which a vector index cannot express. Shared by the CLI and the MCP server.
|
|
6
|
+
|
|
7
|
+
A tool can be denied two ways, both honored here:
|
|
8
|
+
- transitively: Tool -[:READS|WRITES]-> Resource -[:GOVERNED_BY]-> Policy(DENY)
|
|
9
|
+
- directly: Tool -[:GOVERNED_BY]-> Policy(DENY)
|
|
10
|
+
|
|
11
|
+
Tool refs accept the exact key ("<server>::<tool>") or a bare name. A bare name
|
|
12
|
+
that matches tools on multiple servers is reported as AMBIGUOUS_TOOL rather than
|
|
13
|
+
silently resolved. ``blast_radius`` auto-detects a resource URI (contains "://")
|
|
14
|
+
vs a policy id.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import asdict, dataclass
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from neo4j import Session
|
|
25
|
+
|
|
26
|
+
from toolgraph.graph.driver import session
|
|
27
|
+
from toolgraph.uris import is_resource_ref, normalize_resource_uri
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_GENERATION_RETRIES = 5
|
|
31
|
+
# Exponential backoff between bracket retries (0.05/0.1/0.2/0.4s). Without
|
|
32
|
+
# it, a strict producer on a graph under sustained write churn re-reads
|
|
33
|
+
# immediately and can burn all five attempts inside one mutation burst —
|
|
34
|
+
# a liveness failure the pause largely avoids for ~750ms worst case.
|
|
35
|
+
_RETRY_BACKOFF_SECONDS = 0.05
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _bracket_backoff(attempt: int) -> None:
|
|
39
|
+
if attempt + 1 < _GENERATION_RETRIES:
|
|
40
|
+
time.sleep(_RETRY_BACKOFF_SECONDS * (2**attempt))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class GraphState:
|
|
45
|
+
"""Collision-safe identity for one concrete graph state."""
|
|
46
|
+
|
|
47
|
+
instance_id: str | None
|
|
48
|
+
generation: int
|
|
49
|
+
governance_digest: str | None = None
|
|
50
|
+
|
|
51
|
+
def token(self) -> dict[str, str | int | None]:
|
|
52
|
+
return asdict(self)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def with_generation(fetch: Callable[[], dict], *, strict: bool = False) -> dict:
|
|
56
|
+
"""Return ``fetch`` with a generation that brackets the graph reads.
|
|
57
|
+
|
|
58
|
+
Neo4j is read-committed rather than snapshot-isolated. Reading the
|
|
59
|
+
generation before and after the query and retrying on a change prevents a
|
|
60
|
+
result from being labelled with a generation that committed midway
|
|
61
|
+
through it. MCP live queries keep their historical self-healing fallback
|
|
62
|
+
after retry exhaustion; persisted artifact producers pass ``strict=True``
|
|
63
|
+
so they fail instead of writing a potentially mislabelled artifact.
|
|
64
|
+
"""
|
|
65
|
+
for attempt in range(_GENERATION_RETRIES):
|
|
66
|
+
before = graph_generation()
|
|
67
|
+
result = fetch()
|
|
68
|
+
if graph_generation() == before:
|
|
69
|
+
return {**result, "graph_generation": before}
|
|
70
|
+
_bracket_backoff(attempt)
|
|
71
|
+
if strict:
|
|
72
|
+
raise RuntimeError("graph generation changed during every read attempt")
|
|
73
|
+
return {**result, "graph_generation": graph_generation()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def with_graph_state(
|
|
77
|
+
fetch: Callable[[], dict],
|
|
78
|
+
*,
|
|
79
|
+
strict: bool = False,
|
|
80
|
+
state_reader: Callable[[], GraphState] | None = None,
|
|
81
|
+
) -> dict:
|
|
82
|
+
"""Return ``fetch`` bracketed by the collision-safe graph state token."""
|
|
83
|
+
read_state = state_reader if state_reader is not None else graph_state
|
|
84
|
+
for attempt in range(_GENERATION_RETRIES):
|
|
85
|
+
before = read_state()
|
|
86
|
+
result = fetch()
|
|
87
|
+
if read_state() == before:
|
|
88
|
+
token = {"instance_id": before.instance_id, "generation": before.generation}
|
|
89
|
+
return {
|
|
90
|
+
**result,
|
|
91
|
+
"graph_generation": before.generation,
|
|
92
|
+
"graph_instance_id": before.instance_id,
|
|
93
|
+
"graph_state": token,
|
|
94
|
+
}
|
|
95
|
+
_bracket_backoff(attempt)
|
|
96
|
+
if strict:
|
|
97
|
+
raise RuntimeError("graph state changed during every read attempt")
|
|
98
|
+
current = read_state()
|
|
99
|
+
return {
|
|
100
|
+
**result,
|
|
101
|
+
"graph_generation": current.generation,
|
|
102
|
+
"graph_instance_id": current.instance_id,
|
|
103
|
+
"graph_state": {
|
|
104
|
+
"instance_id": current.instance_id,
|
|
105
|
+
"generation": current.generation,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
RESOLVE_BATCH_SIZE = 4096
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_tool_refs(s: Session, refs: list[str]) -> dict[str, list[str]]:
|
|
114
|
+
"""Resolve unique refs in bounded batches on the caller's transaction.
|
|
115
|
+
|
|
116
|
+
Qualified keys and bare names stay separate, preserving index-friendly
|
|
117
|
+
resolution and every ambiguous match. Empty and repeated refs cost no
|
|
118
|
+
additional queries. Results are deterministic on both graph backends.
|
|
119
|
+
"""
|
|
120
|
+
unique = sorted(set(refs))
|
|
121
|
+
resolved: dict[str, list[str]] = {ref: [] for ref in unique}
|
|
122
|
+
qualified = [ref for ref in unique if "::" in ref]
|
|
123
|
+
bare = [ref for ref in unique if "::" not in ref]
|
|
124
|
+
for start in range(0, len(qualified), RESOLVE_BATCH_SIZE):
|
|
125
|
+
for row in s.run(
|
|
126
|
+
"MATCH (t:Tool) WHERE t.key IN $keys RETURN t.key AS key",
|
|
127
|
+
keys=qualified[start:start + RESOLVE_BATCH_SIZE],
|
|
128
|
+
):
|
|
129
|
+
resolved[row["key"]].append(row["key"])
|
|
130
|
+
for start in range(0, len(bare), RESOLVE_BATCH_SIZE):
|
|
131
|
+
for row in s.run(
|
|
132
|
+
"MATCH (t:Tool) WHERE t.name IN $names "
|
|
133
|
+
"RETURN t.name AS name, t.key AS key",
|
|
134
|
+
names=bare[start:start + RESOLVE_BATCH_SIZE],
|
|
135
|
+
):
|
|
136
|
+
resolved[row["name"]].append(row["key"])
|
|
137
|
+
return {ref: sorted(keys) for ref, keys in resolved.items()}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_tool_keys(s: Session, ref: str) -> list[str]:
|
|
141
|
+
"""All matches for one exact qualified key or bare name."""
|
|
142
|
+
return resolve_tool_refs(s, [ref])[ref]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def graph_generation() -> int:
|
|
146
|
+
"""Current graph generation; 0 when no GraphMeta node exists (empty/reset graph).
|
|
147
|
+
|
|
148
|
+
ADR-0004: a monotonic id bumped in the same transaction as every successful
|
|
149
|
+
crawl load and manifest ingest. Consumers use it for cache invalidation
|
|
150
|
+
(one integer comparison instead of re-running audit queries) and for
|
|
151
|
+
pinning selection-telemetry rows to a replayable graph state.
|
|
152
|
+
"""
|
|
153
|
+
with session() as s:
|
|
154
|
+
rec = s.run(
|
|
155
|
+
"MATCH (m:GraphMeta {id:'singleton'}) "
|
|
156
|
+
"RETURN coalesce(m.generation, 0) AS generation"
|
|
157
|
+
).single()
|
|
158
|
+
return rec["generation"] if rec else 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def graph_state() -> GraphState:
|
|
162
|
+
"""Return the current collision-safe graph state and governance digest."""
|
|
163
|
+
with session() as s:
|
|
164
|
+
rec = s.run(
|
|
165
|
+
"MATCH (m:GraphMeta {id:'singleton'}) "
|
|
166
|
+
"RETURN m.instance_id AS instance_id, "
|
|
167
|
+
"coalesce(m.generation, 0) AS generation, "
|
|
168
|
+
"m.governance_digest AS governance_digest"
|
|
169
|
+
).single()
|
|
170
|
+
if rec is None:
|
|
171
|
+
return GraphState(instance_id=None, generation=0)
|
|
172
|
+
return GraphState(
|
|
173
|
+
instance_id=rec["instance_id"],
|
|
174
|
+
generation=rec["generation"],
|
|
175
|
+
governance_digest=rec["governance_digest"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def exposed_tool_contracts() -> list[dict]:
|
|
180
|
+
"""Canonical crawl facts for every tool currently exposed by a server."""
|
|
181
|
+
with session() as s:
|
|
182
|
+
rows = s.run(
|
|
183
|
+
"""
|
|
184
|
+
MATCH (server:MCPServer)-[:EXPOSES]->(tool:Tool)
|
|
185
|
+
RETURN tool.key AS tool_key,
|
|
186
|
+
server.name AS server,
|
|
187
|
+
tool.name AS name,
|
|
188
|
+
tool.description AS description,
|
|
189
|
+
tool.input_schema AS input_schema,
|
|
190
|
+
tool.read_only_hint AS read_only_hint,
|
|
191
|
+
tool.destructive_hint AS destructive_hint,
|
|
192
|
+
tool.idempotent_hint AS idempotent_hint,
|
|
193
|
+
tool.open_world_hint AS open_world_hint
|
|
194
|
+
ORDER BY tool.key
|
|
195
|
+
"""
|
|
196
|
+
)
|
|
197
|
+
result = []
|
|
198
|
+
for row in rows:
|
|
199
|
+
item = dict(row)
|
|
200
|
+
raw_schema = item.get("input_schema")
|
|
201
|
+
item["input_schema"] = json.loads(raw_schema) if raw_schema else None
|
|
202
|
+
result.append(item)
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def agent_exists(agent: str) -> bool:
|
|
207
|
+
"""Is there an Agent node with this id? Used by the CLI + MCP surfaces to
|
|
208
|
+
return a discriminated AGENT_NOT_FOUND signal instead of silently
|
|
209
|
+
returning ``[]`` (which is indistinguishable from 'no unsafe tools')."""
|
|
210
|
+
with session() as s:
|
|
211
|
+
return s.run(
|
|
212
|
+
"RETURN EXISTS { MATCH (:Agent {id:$agent}) } AS ok", agent=agent
|
|
213
|
+
).single()["ok"]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _agent_ids() -> list[str]:
|
|
217
|
+
with session() as s:
|
|
218
|
+
return [r["id"] for r in s.run("MATCH (a:Agent) RETURN a.id AS id ORDER BY id")]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _resource_path(tool: str, mode: str, resource: str, policy: str) -> str:
|
|
222
|
+
return f"({tool}) -{mode}-> ({resource}) -GOVERNED_BY-> ({policy}:DENY)"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _tool_path(tool: str, policy: str) -> str:
|
|
226
|
+
return f"({tool}) -GOVERNED_BY-> ({policy}:DENY)"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _radius_path(agent: str | None, tool: str | None, mode: str | None, resource: str) -> str | None:
|
|
230
|
+
if agent and tool:
|
|
231
|
+
return f"({agent}) -CAN_CALL-> ({tool}) -{mode}-> ({resource})"
|
|
232
|
+
if tool:
|
|
233
|
+
return f"({tool}) -{mode}-> ({resource})"
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _deny_evidence_batch(
|
|
238
|
+
s: Session, keys: list[str], agent: str | None = None
|
|
239
|
+
) -> dict[str, list[dict]]:
|
|
240
|
+
"""All ways each tool in ``keys`` reaches a DENY policy, in two queries.
|
|
241
|
+
|
|
242
|
+
Batched so the selector surface (ADR-0005) can evaluate N candidates
|
|
243
|
+
without N round trips — per-candidate calls scale linearly, which is
|
|
244
|
+
unacceptable on the online selection path.
|
|
245
|
+
|
|
246
|
+
Each row carries:
|
|
247
|
+
- ``provenance``: the load-bearing edge's provenance — the data-access edge
|
|
248
|
+
for via='resource', the GOVERNED_BY edge for via='tool'.
|
|
249
|
+
- ``policy_provenance`` (optional, via='resource' only): the GOVERNED_BY
|
|
250
|
+
binding's provenance, attached only when an author cited evidence for
|
|
251
|
+
the resource↔policy binding (otherwise omitted to keep output clean).
|
|
252
|
+
- ``classification`` (when ``agent`` given): ``violation`` or
|
|
253
|
+
``authorized_but_governed`` based on a matching ExpectedException.
|
|
254
|
+
"""
|
|
255
|
+
evidence: dict[str, list[dict]] = {k: [] for k in keys}
|
|
256
|
+
# Codex PR #1 review: detect exception match via `exc IS NOT NULL`, NOT via
|
|
257
|
+
# `exc.reason IS NOT NULL`. ``reason`` is optional on ExpectedException;
|
|
258
|
+
# using it as the match signal would misclassify a valid no-reason
|
|
259
|
+
# exception as a violation.
|
|
260
|
+
#
|
|
261
|
+
# Exceptions are COLLECTED, not row-multiplied: a wildcard (resource IS
|
|
262
|
+
# NULL) and a resource-specific exception can both match one deny path,
|
|
263
|
+
# and DISTINCT cannot collapse rows whose ``reason`` strings differ —
|
|
264
|
+
# which duplicated evidence rows. One row per path; the most specific
|
|
265
|
+
# exception (resource-bound over wildcard) supplies the reason.
|
|
266
|
+
for r in s.run(
|
|
267
|
+
"""
|
|
268
|
+
MATCH (t:Tool)-[acc:READS|WRITES]->(r:Resource)-[gov:GOVERNED_BY]->(p:Policy {effect:'DENY'})
|
|
269
|
+
WHERE t.key IN $keys
|
|
270
|
+
OPTIONAL MATCH (exc:ExpectedException {policy:p.id})
|
|
271
|
+
WHERE exc.tool_key = t.key
|
|
272
|
+
AND ($agent IS NULL OR exc.agent = $agent)
|
|
273
|
+
AND (exc.resource IS NULL OR exc.resource = r.uri)
|
|
274
|
+
WITH t.key AS key, type(acc) AS mode, r.uri AS resource, p.id AS policy,
|
|
275
|
+
acc.source AS source, acc.confidence AS confidence, acc.evidence AS evidence,
|
|
276
|
+
gov.source AS gov_source, gov.confidence AS gov_confidence, gov.evidence AS gov_evidence,
|
|
277
|
+
collect(DISTINCT CASE WHEN exc IS NULL THEN NULL ELSE
|
|
278
|
+
{resource: exc.resource, reason: exc.reason} END) AS exceptions
|
|
279
|
+
RETURN key, mode, resource, policy, source, confidence, evidence,
|
|
280
|
+
gov_source, gov_confidence, gov_evidence,
|
|
281
|
+
exceptions
|
|
282
|
+
ORDER BY key, resource, policy
|
|
283
|
+
""",
|
|
284
|
+
keys=keys,
|
|
285
|
+
agent=agent,
|
|
286
|
+
):
|
|
287
|
+
key = r["key"]
|
|
288
|
+
d = {
|
|
289
|
+
"via": "resource",
|
|
290
|
+
"mode": r["mode"],
|
|
291
|
+
"resource": r["resource"],
|
|
292
|
+
"policy": r["policy"],
|
|
293
|
+
"path": _resource_path(_name(key), r["mode"], r["resource"], r["policy"]),
|
|
294
|
+
"provenance": _prov(r),
|
|
295
|
+
}
|
|
296
|
+
policy_prov = _gov_prov(r)
|
|
297
|
+
if policy_prov is not None:
|
|
298
|
+
d["policy_provenance"] = policy_prov
|
|
299
|
+
if agent is not None:
|
|
300
|
+
exception = _picked_exception(r)
|
|
301
|
+
d["classification"] = (
|
|
302
|
+
"authorized_but_governed" if exception else "violation"
|
|
303
|
+
)
|
|
304
|
+
if exception and exception.get("reason") is not None:
|
|
305
|
+
d["exception_reason"] = exception["reason"]
|
|
306
|
+
evidence[key].append(d)
|
|
307
|
+
for r in s.run(
|
|
308
|
+
"""
|
|
309
|
+
MATCH (t:Tool)-[g:GOVERNED_BY]->(p:Policy {effect:'DENY'})
|
|
310
|
+
WHERE t.key IN $keys
|
|
311
|
+
OPTIONAL MATCH (exc:ExpectedException {policy:p.id})
|
|
312
|
+
WHERE exc.tool_key = t.key
|
|
313
|
+
AND ($agent IS NULL OR exc.agent = $agent) AND exc.resource IS NULL
|
|
314
|
+
WITH t.key AS key, p.id AS policy,
|
|
315
|
+
g.source AS source, g.confidence AS confidence, g.evidence AS evidence,
|
|
316
|
+
collect(DISTINCT CASE WHEN exc IS NULL THEN NULL ELSE
|
|
317
|
+
{resource: exc.resource, reason: exc.reason} END) AS exceptions
|
|
318
|
+
RETURN key, policy, source, confidence, evidence,
|
|
319
|
+
exceptions
|
|
320
|
+
ORDER BY key, policy
|
|
321
|
+
""",
|
|
322
|
+
keys=keys,
|
|
323
|
+
agent=agent,
|
|
324
|
+
):
|
|
325
|
+
key = r["key"]
|
|
326
|
+
d = {
|
|
327
|
+
"via": "tool",
|
|
328
|
+
"policy": r["policy"],
|
|
329
|
+
"path": _tool_path(_name(key), r["policy"]),
|
|
330
|
+
"provenance": _prov(r),
|
|
331
|
+
}
|
|
332
|
+
if agent is not None:
|
|
333
|
+
exception = _picked_exception(r)
|
|
334
|
+
d["classification"] = (
|
|
335
|
+
"authorized_but_governed" if exception else "violation"
|
|
336
|
+
)
|
|
337
|
+
if exception and exception.get("reason") is not None:
|
|
338
|
+
d["exception_reason"] = exception["reason"]
|
|
339
|
+
evidence[key].append(d)
|
|
340
|
+
return evidence
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _deny_evidence_for(s: Session, key: str, agent: str | None = None) -> list[dict]:
|
|
344
|
+
"""Single-tool view over ``_deny_evidence_batch`` (see there for row shape)."""
|
|
345
|
+
return _deny_evidence_batch(s, [key], agent=agent)[key]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _prov(row) -> dict:
|
|
349
|
+
"""Provenance fields as a sub-dict — honest pass-through.
|
|
350
|
+
|
|
351
|
+
No silent defaulting: a null ``source`` stays null so a reader can tell
|
|
352
|
+
"no provenance recorded" from "operator said so". Every authored edge has
|
|
353
|
+
these set by ingest; nulls only appear on edge types no query currently
|
|
354
|
+
surfaces.
|
|
355
|
+
"""
|
|
356
|
+
return {
|
|
357
|
+
"source": row["source"],
|
|
358
|
+
"confidence": row["confidence"],
|
|
359
|
+
"evidence": row["evidence"],
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _gov_prov(row) -> dict | None:
|
|
364
|
+
"""Policy-binding (GOVERNED_BY edge) provenance for resource-branch rows.
|
|
365
|
+
|
|
366
|
+
Returns None for the default operator_asserted/high-with-no-evidence
|
|
367
|
+
case — that's noise, not signal. Only attaches when an author cited a
|
|
368
|
+
real source pointer for the (resource, policy) binding itself.
|
|
369
|
+
"""
|
|
370
|
+
evidence = row["gov_evidence"]
|
|
371
|
+
source = row["gov_source"]
|
|
372
|
+
if evidence is None and (source is None or source == "operator_asserted"):
|
|
373
|
+
return None
|
|
374
|
+
return {
|
|
375
|
+
"source": source,
|
|
376
|
+
"confidence": row["gov_confidence"],
|
|
377
|
+
"evidence": evidence,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _picked_exception(row) -> dict | None:
|
|
382
|
+
"""Prefer a resource-specific exception over a wildcard."""
|
|
383
|
+
exceptions = [item for item in (row.get("exceptions") or []) if item]
|
|
384
|
+
return next(
|
|
385
|
+
(item for item in exceptions if item.get("resource") is not None),
|
|
386
|
+
exceptions[0] if exceptions else None,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _name(key: str) -> str:
|
|
391
|
+
return key.split("::", 1)[1] if "::" in key else key
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def check_access(agent: str, tool: str) -> dict:
|
|
395
|
+
"""Can the agent call the tool, and does the call cross a DENY policy?
|
|
396
|
+
|
|
397
|
+
verdict: AGENT_NOT_FOUND | TOOL_NOT_FOUND | AMBIGUOUS_TOOL | NOT_GRANTED | DENY | ALLOW.
|
|
398
|
+
|
|
399
|
+
When verdict=DENY, the result includes ``all_authorized: bool`` — True when
|
|
400
|
+
every deny_evidence row is ``authorized_but_governed`` (operator declared the
|
|
401
|
+
reach intentional). Lets callers branch on "real violation" vs "expected
|
|
402
|
+
DENY" without re-aggregating row classifications.
|
|
403
|
+
"""
|
|
404
|
+
with session() as s:
|
|
405
|
+
agent_present = s.run(
|
|
406
|
+
"RETURN EXISTS { MATCH (:Agent {id:$agent}) } AS ok", agent=agent
|
|
407
|
+
).single()["ok"]
|
|
408
|
+
if not agent_present:
|
|
409
|
+
# Three-state ``found``: True/False when tool resolution ran,
|
|
410
|
+
# None when it didn't (AGENT_NOT_FOUND fires before tool lookup).
|
|
411
|
+
# Hard-coding False would lie about a tool that actually exists;
|
|
412
|
+
# README documents the contract so callers can use
|
|
413
|
+
# ``result.get('found')`` instead of indexing.
|
|
414
|
+
return {
|
|
415
|
+
"agent": agent,
|
|
416
|
+
"tool": tool,
|
|
417
|
+
"agent_found": False,
|
|
418
|
+
"found": None,
|
|
419
|
+
"verdict": "AGENT_NOT_FOUND",
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
keys = resolve_tool_keys(s, tool)
|
|
423
|
+
if not keys:
|
|
424
|
+
return {
|
|
425
|
+
"agent": agent,
|
|
426
|
+
"tool": tool,
|
|
427
|
+
"agent_found": True,
|
|
428
|
+
"found": False,
|
|
429
|
+
"verdict": "TOOL_NOT_FOUND",
|
|
430
|
+
}
|
|
431
|
+
if len(keys) > 1:
|
|
432
|
+
return {
|
|
433
|
+
"agent": agent,
|
|
434
|
+
"tool": tool,
|
|
435
|
+
"agent_found": True,
|
|
436
|
+
"found": True,
|
|
437
|
+
"verdict": "AMBIGUOUS_TOOL",
|
|
438
|
+
"candidates": keys,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
key = keys[0]
|
|
442
|
+
meta = s.run(
|
|
443
|
+
"""
|
|
444
|
+
MATCH (t:Tool {key:$key})
|
|
445
|
+
RETURN t.name AS name, t.server AS server,
|
|
446
|
+
EXISTS { MATCH (:Agent {id:$agent})-[:CAN_CALL]->(t) } AS permitted
|
|
447
|
+
""",
|
|
448
|
+
key=key,
|
|
449
|
+
agent=agent,
|
|
450
|
+
).single()
|
|
451
|
+
if meta is None: # tool deleted between resolve and this read
|
|
452
|
+
return {
|
|
453
|
+
"agent": agent,
|
|
454
|
+
"tool": tool,
|
|
455
|
+
"agent_found": True,
|
|
456
|
+
"found": False,
|
|
457
|
+
"verdict": "TOOL_NOT_FOUND",
|
|
458
|
+
}
|
|
459
|
+
deny = _deny_evidence_for(s, key, agent=agent)
|
|
460
|
+
|
|
461
|
+
if not meta["permitted"]:
|
|
462
|
+
verdict = "NOT_GRANTED"
|
|
463
|
+
elif deny:
|
|
464
|
+
verdict = "DENY"
|
|
465
|
+
else:
|
|
466
|
+
verdict = "ALLOW"
|
|
467
|
+
|
|
468
|
+
result = {
|
|
469
|
+
"agent": agent,
|
|
470
|
+
"tool": meta["name"],
|
|
471
|
+
"tool_key": key,
|
|
472
|
+
"server": meta["server"],
|
|
473
|
+
"agent_found": True,
|
|
474
|
+
"found": True,
|
|
475
|
+
"permitted": meta["permitted"],
|
|
476
|
+
"verdict": verdict,
|
|
477
|
+
"deny_evidence": deny,
|
|
478
|
+
}
|
|
479
|
+
if verdict == "DENY":
|
|
480
|
+
result["all_authorized"] = all(
|
|
481
|
+
d.get("classification") == "authorized_but_governed" for d in deny
|
|
482
|
+
)
|
|
483
|
+
return result
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def unsafe_callable_tools(agent: str) -> list[dict]:
|
|
487
|
+
"""Tools the agent can call that reach a DENY policy (via resource or directly).
|
|
488
|
+
|
|
489
|
+
Each row carries:
|
|
490
|
+
- ``provenance``: load-bearing edge — data-access for via='resource',
|
|
491
|
+
GOVERNED_BY for via='tool'.
|
|
492
|
+
- ``policy_provenance`` (via='resource', optional): GOVERNED_BY binding's
|
|
493
|
+
provenance when the author cited a source for the resource↔policy link.
|
|
494
|
+
- ``classification``: ``violation`` (no exception matches) or
|
|
495
|
+
``authorized_but_governed`` (operator declared this reach intentional via
|
|
496
|
+
``expected_exceptions`` in the manifest). Replaces the old bare 'unsafe'
|
|
497
|
+
framing so operators don't have to mentally filter by-design grants.
|
|
498
|
+
- ``exception_reason`` when classification is ``authorized_but_governed``.
|
|
499
|
+
|
|
500
|
+
Returns ``[]`` for an unknown agent — pair with ``check_access`` (which has
|
|
501
|
+
an ``AGENT_NOT_FOUND`` verdict) if you need to distinguish "no unsafe
|
|
502
|
+
tools" from "agent doesn't exist".
|
|
503
|
+
"""
|
|
504
|
+
with session() as s:
|
|
505
|
+
rows = _unsafe_callable_rows(s, [agent])
|
|
506
|
+
for d in rows:
|
|
507
|
+
d.pop("agent", None)
|
|
508
|
+
return rows
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _unsafe_callable_rows(s: Session, agents: list[str]) -> list[dict]:
|
|
512
|
+
"""Unsafe-callable rows for every agent in ``agents``, in two queries.
|
|
513
|
+
|
|
514
|
+
Batched so ``audit_report`` costs a fixed number of queries instead of
|
|
515
|
+
two per agent. Rows carry an ``agent`` key and come back ordered by
|
|
516
|
+
(agent, tool, resource) — the same order the per-agent loop produced.
|
|
517
|
+
"""
|
|
518
|
+
rows: list[dict] = []
|
|
519
|
+
# Same exception-collection rule as _deny_evidence_for: one row per
|
|
520
|
+
# deny path, the most specific matching exception wins the reason.
|
|
521
|
+
for r in s.run(
|
|
522
|
+
"""
|
|
523
|
+
MATCH (a:Agent)-[:CAN_CALL]->(t:Tool)
|
|
524
|
+
-[acc:READS|WRITES]->(res:Resource)-[gov:GOVERNED_BY]->(p:Policy {effect:'DENY'})
|
|
525
|
+
WHERE a.id IN $agents
|
|
526
|
+
OPTIONAL MATCH (exc:ExpectedException {tool_key:t.key, policy:p.id})
|
|
527
|
+
WHERE exc.agent = a.id AND (exc.resource IS NULL OR exc.resource = res.uri)
|
|
528
|
+
WITH a.id AS agent, t.key AS tool_key, t.name AS tool, t.server AS server,
|
|
529
|
+
type(acc) AS mode, res.uri AS resource, p.id AS policy,
|
|
530
|
+
acc.source AS source, acc.confidence AS confidence, acc.evidence AS evidence,
|
|
531
|
+
gov.source AS gov_source, gov.confidence AS gov_confidence, gov.evidence AS gov_evidence,
|
|
532
|
+
collect(DISTINCT CASE WHEN exc IS NULL THEN NULL ELSE
|
|
533
|
+
{resource: exc.resource, reason: exc.reason} END) AS exceptions
|
|
534
|
+
RETURN agent, tool_key, tool, server, 'resource' AS via, mode, resource, policy,
|
|
535
|
+
source, confidence, evidence, gov_source, gov_confidence, gov_evidence,
|
|
536
|
+
exceptions
|
|
537
|
+
ORDER BY agent, tool, resource
|
|
538
|
+
""",
|
|
539
|
+
agents=agents,
|
|
540
|
+
):
|
|
541
|
+
d = dict(r)
|
|
542
|
+
d["path"] = _resource_path(d["tool"], d["mode"], d["resource"], d["policy"])
|
|
543
|
+
d["provenance"] = _prov(r)
|
|
544
|
+
exception = _picked_exception(r)
|
|
545
|
+
policy_prov = _gov_prov(r)
|
|
546
|
+
if policy_prov is not None:
|
|
547
|
+
d["policy_provenance"] = policy_prov
|
|
548
|
+
d["classification"] = (
|
|
549
|
+
"authorized_but_governed" if exception else "violation"
|
|
550
|
+
)
|
|
551
|
+
d["exception_reason"] = exception.get("reason") if exception else None
|
|
552
|
+
for k in ("source", "confidence", "evidence",
|
|
553
|
+
"gov_source", "gov_confidence", "gov_evidence",
|
|
554
|
+
"exceptions"):
|
|
555
|
+
d.pop(k, None)
|
|
556
|
+
if d.get("exception_reason") is None:
|
|
557
|
+
d.pop("exception_reason", None)
|
|
558
|
+
rows.append(d)
|
|
559
|
+
for r in s.run(
|
|
560
|
+
"""
|
|
561
|
+
MATCH (a:Agent)-[:CAN_CALL]->(t:Tool)-[g:GOVERNED_BY]->(p:Policy {effect:'DENY'})
|
|
562
|
+
WHERE a.id IN $agents
|
|
563
|
+
OPTIONAL MATCH (exc:ExpectedException {tool_key:t.key, policy:p.id})
|
|
564
|
+
WHERE exc.agent = a.id AND exc.resource IS NULL
|
|
565
|
+
WITH a.id AS agent, t.key AS tool_key, t.name AS tool, t.server AS server, p.id AS policy,
|
|
566
|
+
g.source AS source, g.confidence AS confidence, g.evidence AS evidence,
|
|
567
|
+
collect(DISTINCT CASE WHEN exc IS NULL THEN NULL ELSE
|
|
568
|
+
{resource: exc.resource, reason: exc.reason} END) AS exceptions
|
|
569
|
+
RETURN agent, tool_key, tool, server, 'tool' AS via, null AS mode, null AS resource,
|
|
570
|
+
policy, source, confidence, evidence,
|
|
571
|
+
exceptions
|
|
572
|
+
ORDER BY agent, tool
|
|
573
|
+
""",
|
|
574
|
+
agents=agents,
|
|
575
|
+
):
|
|
576
|
+
d = dict(r)
|
|
577
|
+
d["path"] = _tool_path(d["tool"], d["policy"])
|
|
578
|
+
d["provenance"] = _prov(r)
|
|
579
|
+
exception = _picked_exception(r)
|
|
580
|
+
d["classification"] = (
|
|
581
|
+
"authorized_but_governed" if exception else "violation"
|
|
582
|
+
)
|
|
583
|
+
d["exception_reason"] = exception.get("reason") if exception else None
|
|
584
|
+
for k in ("source", "confidence", "evidence", "exceptions"):
|
|
585
|
+
d.pop(k, None)
|
|
586
|
+
if d.get("exception_reason") is None:
|
|
587
|
+
d.pop("exception_reason", None)
|
|
588
|
+
rows.append(d)
|
|
589
|
+
return rows
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def audit_report(include_grants: bool = False) -> dict:
|
|
593
|
+
"""Whole-graph operational audit summary.
|
|
594
|
+
|
|
595
|
+
This is an aggregation layer over the existing advisory queries. It keeps
|
|
596
|
+
their row shapes intact and adds counts/status so operators and CI can see
|
|
597
|
+
the graph's current risk posture without stitching six commands together.
|
|
598
|
+
"""
|
|
599
|
+
unsafe_violations: list[dict] = []
|
|
600
|
+
authorized_but_governed: list[dict] = []
|
|
601
|
+
agents = _agent_ids()
|
|
602
|
+
# One batched pass instead of two whole-graph traversals per agent.
|
|
603
|
+
# Rows regroup agent-major so the output order matches the historical
|
|
604
|
+
# per-agent loop exactly.
|
|
605
|
+
by_agent: dict[str, list[dict]] = {agent: [] for agent in agents}
|
|
606
|
+
if agents:
|
|
607
|
+
with session() as s:
|
|
608
|
+
for row in _unsafe_callable_rows(s, agents):
|
|
609
|
+
by_agent[row["agent"]].append(row)
|
|
610
|
+
for agent in agents:
|
|
611
|
+
for row in by_agent[agent]:
|
|
612
|
+
if row.get("classification") == "violation":
|
|
613
|
+
unsafe_violations.append(row)
|
|
614
|
+
else:
|
|
615
|
+
authorized_but_governed.append(row)
|
|
616
|
+
|
|
617
|
+
findings = {
|
|
618
|
+
"unsafe_violations": unsafe_violations,
|
|
619
|
+
"authorized_but_governed": authorized_but_governed,
|
|
620
|
+
"drifted_tools": drifted_tools(),
|
|
621
|
+
"unmapped_tools": unmapped_tools(),
|
|
622
|
+
"unbacked_edges": unbacked_edges(include_grants=include_grants),
|
|
623
|
+
"orphan_policies": orphan_policies(),
|
|
624
|
+
"destructive_unsafeguarded": destructive_unsafeguarded(),
|
|
625
|
+
"annotation_contradictions": annotation_contradictions(),
|
|
626
|
+
}
|
|
627
|
+
counts = {name: len(rows) for name, rows in findings.items()}
|
|
628
|
+
blocking = [
|
|
629
|
+
name
|
|
630
|
+
for name in ("unsafe_violations", "drifted_tools")
|
|
631
|
+
if counts[name] > 0
|
|
632
|
+
]
|
|
633
|
+
if blocking:
|
|
634
|
+
status = "fail"
|
|
635
|
+
elif any(counts.values()):
|
|
636
|
+
status = "warn"
|
|
637
|
+
else:
|
|
638
|
+
status = "pass"
|
|
639
|
+
return {
|
|
640
|
+
"status": status,
|
|
641
|
+
"summary": {
|
|
642
|
+
"agents": len(agents),
|
|
643
|
+
"counts": counts,
|
|
644
|
+
"blocking_findings": blocking,
|
|
645
|
+
"include_grants": include_grants,
|
|
646
|
+
},
|
|
647
|
+
"findings": findings,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# --- Negative-truth / drift queries -------------------------------------
|
|
652
|
+
#
|
|
653
|
+
# Audit views that surface what the graph DOESN'T say but probably should.
|
|
654
|
+
# Codex review of the gate exhibit noted that an advisory analyzer that only
|
|
655
|
+
# answers positive reachability questions can look more authoritative than it
|
|
656
|
+
# is; these queries make the unspecified, the unsupported, and the stale
|
|
657
|
+
# parts of the graph visible.
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def unmapped_tools(only_granted: bool = True) -> list[dict]:
|
|
661
|
+
"""Tools with no authored READS/WRITES — data-flow effects unclassified.
|
|
662
|
+
|
|
663
|
+
Default scopes to tools an agent has a CAN_CALL grant on (those matter
|
|
664
|
+
most for safety review). Pass ``only_granted=False`` to include all
|
|
665
|
+
crawled tools with no data-access edges.
|
|
666
|
+
"""
|
|
667
|
+
cypher = (
|
|
668
|
+
"""
|
|
669
|
+
MATCH (t:Tool)
|
|
670
|
+
WHERE NOT EXISTS { MATCH (t)-[:READS|WRITES]->() }
|
|
671
|
+
"""
|
|
672
|
+
+ (
|
|
673
|
+
" AND EXISTS { MATCH (:Agent)-[:CAN_CALL]->(t) }\n"
|
|
674
|
+
if only_granted
|
|
675
|
+
else ""
|
|
676
|
+
)
|
|
677
|
+
+ """
|
|
678
|
+
OPTIONAL MATCH (a:Agent)-[:CAN_CALL]->(t)
|
|
679
|
+
RETURN t.key AS tool_key, t.name AS tool, t.server AS server,
|
|
680
|
+
collect(DISTINCT a.id) AS granted_to
|
|
681
|
+
ORDER BY tool_key
|
|
682
|
+
"""
|
|
683
|
+
)
|
|
684
|
+
with session() as s:
|
|
685
|
+
return [dict(r) for r in s.run(cypher)]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def orphan_policies() -> list[dict]:
|
|
689
|
+
"""Policies that no agent currently reaches — likely obsolete or over-narrow.
|
|
690
|
+
|
|
691
|
+
A policy is an orphan when neither a resource it governs is read/written
|
|
692
|
+
by a CAN_CALL-grantable tool, nor a tool it governs directly has any
|
|
693
|
+
caller. Useful before pruning policies that look authoritative but in
|
|
694
|
+
practice gate nothing.
|
|
695
|
+
"""
|
|
696
|
+
cypher = """
|
|
697
|
+
MATCH (p:Policy)
|
|
698
|
+
WHERE NOT EXISTS {
|
|
699
|
+
MATCH (:Agent)-[:CAN_CALL]->(:Tool)-[:READS|WRITES]->(:Resource)-[:GOVERNED_BY]->(p)
|
|
700
|
+
}
|
|
701
|
+
AND NOT EXISTS {
|
|
702
|
+
MATCH (:Agent)-[:CAN_CALL]->(:Tool)-[:GOVERNED_BY]->(p)
|
|
703
|
+
}
|
|
704
|
+
OPTIONAL MATCH (r:Resource)-[:GOVERNED_BY]->(p)
|
|
705
|
+
OPTIONAL MATCH (t:Tool)-[:GOVERNED_BY]->(p)
|
|
706
|
+
RETURN p.id AS policy, p.effect AS effect, p.scope AS scope,
|
|
707
|
+
collect(DISTINCT r.uri) AS resources_governed,
|
|
708
|
+
collect(DISTINCT t.key) AS tools_governed_directly
|
|
709
|
+
ORDER BY policy
|
|
710
|
+
"""
|
|
711
|
+
with session() as s:
|
|
712
|
+
return [dict(r) for r in s.run(cypher)]
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def unbacked_edges(include_grants: bool = False) -> list[dict]:
|
|
716
|
+
"""Authored edges with no usable evidence pointer.
|
|
717
|
+
|
|
718
|
+
These are unsupported claims — the gate-review fiction was exactly this
|
|
719
|
+
shape. Surfacing them at audit time lets operators decide which assertions
|
|
720
|
+
need source citation before being trusted. This includes both
|
|
721
|
+
operator_asserted and inferred claims; either is unreviewable without a
|
|
722
|
+
supporting pointer.
|
|
723
|
+
|
|
724
|
+
``CAN_CALL`` grants are EXCLUDED by default: a grant is intent declaration,
|
|
725
|
+
not a factual claim about the world, so requiring evidence on every grant
|
|
726
|
+
pollutes the output with un-fixable rows. Pass ``include_grants=True`` to
|
|
727
|
+
audit grants too (useful when grants should reference a ticket / change
|
|
728
|
+
request). The remaining edges (READS/WRITES/GOVERNED_BY) are claims about
|
|
729
|
+
what the world does or which policy applies, and SHOULD carry evidence.
|
|
730
|
+
"""
|
|
731
|
+
edge_types = (
|
|
732
|
+
"CAN_CALL|READS|WRITES|GOVERNED_BY" if include_grants
|
|
733
|
+
else "READS|WRITES|GOVERNED_BY"
|
|
734
|
+
)
|
|
735
|
+
cypher = f"""
|
|
736
|
+
MATCH (src)-[r:{edge_types}]->(dst)
|
|
737
|
+
WHERE r.evidence IS NULL OR trim(r.evidence) = ''
|
|
738
|
+
WITH type(r) AS edge_type,
|
|
739
|
+
labels(src)[0] AS src_label,
|
|
740
|
+
coalesce(src.id, src.key, src.uri, src.name) AS src_id,
|
|
741
|
+
labels(dst)[0] AS dst_label,
|
|
742
|
+
coalesce(dst.id, dst.key, dst.uri, dst.name) AS dst_id,
|
|
743
|
+
coalesce(r.source, 'operator_asserted') AS source,
|
|
744
|
+
coalesce(r.confidence, 'high') AS confidence
|
|
745
|
+
RETURN edge_type, src_label, src_id AS src, dst_label, dst_id AS dst,
|
|
746
|
+
source, confidence
|
|
747
|
+
ORDER BY edge_type, src_id, dst_id
|
|
748
|
+
"""
|
|
749
|
+
with session() as s:
|
|
750
|
+
return [dict(r) for r in s.run(cypher)]
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def drifted_tools() -> list[dict]:
|
|
754
|
+
"""Tools with authored governance but no live EXPOSES edge.
|
|
755
|
+
|
|
756
|
+
Happens when a server stops advertising a tool but the operator hasn't
|
|
757
|
+
re-run ingest-manifest. Loader keeps these so a DENY never silently
|
|
758
|
+
becomes ALLOW; this query lets operators find and resolve them.
|
|
759
|
+
"""
|
|
760
|
+
cypher = """
|
|
761
|
+
MATCH (t:Tool)
|
|
762
|
+
WHERE NOT EXISTS { MATCH (:MCPServer)-[:EXPOSES]->(t) }
|
|
763
|
+
AND EXISTS { MATCH (t)-[:CAN_CALL|READS|WRITES|GOVERNED_BY]-() }
|
|
764
|
+
OPTIONAL MATCH (a:Agent)-[:CAN_CALL]->(t)
|
|
765
|
+
OPTIONAL MATCH (t)-[acc:READS|WRITES]->(res:Resource)
|
|
766
|
+
RETURN t.key AS tool_key, t.name AS tool, t.server AS server,
|
|
767
|
+
collect(DISTINCT a.id) AS granted_to,
|
|
768
|
+
collect(DISTINCT {mode:type(acc), resource:res.uri}) AS data_access
|
|
769
|
+
ORDER BY tool_key
|
|
770
|
+
"""
|
|
771
|
+
with session() as s:
|
|
772
|
+
rows = []
|
|
773
|
+
for row in s.run(cypher):
|
|
774
|
+
item = dict(row)
|
|
775
|
+
item["granted_to"] = [value for value in (item["granted_to"] or []) if value]
|
|
776
|
+
item["data_access"] = [
|
|
777
|
+
value
|
|
778
|
+
for value in (item["data_access"] or [])
|
|
779
|
+
if value and value.get("mode") is not None and value.get("resource") is not None
|
|
780
|
+
]
|
|
781
|
+
rows.append(item)
|
|
782
|
+
return rows
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _annotation_provenance(exposes_evidence: str | None) -> dict:
|
|
786
|
+
"""Provenance for a tool-annotation claim (ADR-0006).
|
|
787
|
+
|
|
788
|
+
Annotations are the server's self-claims: crawled facts, but unverified —
|
|
789
|
+
so ``confidence: medium``, never the ``high`` of EXPOSES/PROVIDES. The
|
|
790
|
+
evidence pointer is the EXPOSES edge's (the annotation arrived on the same
|
|
791
|
+
crawl pass); null when the tool has drifted off its server.
|
|
792
|
+
"""
|
|
793
|
+
return {"source": "crawled", "confidence": "medium", "evidence": exposes_evidence}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def destructive_unsafeguarded() -> list[dict]:
|
|
797
|
+
"""Tools hinting ``destructiveHint: true`` with no GOVERNED_BY path at all —
|
|
798
|
+
neither directly on the tool nor via any resource it reads/writes.
|
|
799
|
+
|
|
800
|
+
This is an authoring priority queue, not a verdict: the server claims the
|
|
801
|
+
tool is destructive, and no operator has bound a policy anywhere near it.
|
|
802
|
+
Annotations never auto-create edges (ADR-0006) — each row cites the
|
|
803
|
+
annotation's provenance and leaves the authoring decision to the operator.
|
|
804
|
+
"""
|
|
805
|
+
cypher = """
|
|
806
|
+
MATCH (t:Tool)
|
|
807
|
+
WHERE t.destructive_hint = true
|
|
808
|
+
AND NOT EXISTS { MATCH (t)-[:GOVERNED_BY]->(:Policy) }
|
|
809
|
+
AND NOT EXISTS { MATCH (t)-[:READS|WRITES]->(:Resource)-[:GOVERNED_BY]->(:Policy) }
|
|
810
|
+
OPTIONAL MATCH (:MCPServer)-[e:EXPOSES]->(t)
|
|
811
|
+
OPTIONAL MATCH (a:Agent)-[:CAN_CALL]->(t)
|
|
812
|
+
OPTIONAL MATCH (t)-[acc:READS|WRITES]->(res:Resource)
|
|
813
|
+
RETURN t.key AS tool_key, t.name AS tool, t.server AS server,
|
|
814
|
+
e.evidence AS exposes_evidence,
|
|
815
|
+
collect(DISTINCT a.id) AS granted_to,
|
|
816
|
+
collect(DISTINCT {mode:type(acc), resource:res.uri}) AS data_access
|
|
817
|
+
ORDER BY tool_key
|
|
818
|
+
"""
|
|
819
|
+
rows: list[dict] = []
|
|
820
|
+
with session() as s:
|
|
821
|
+
for r in s.run(cypher):
|
|
822
|
+
d = dict(r)
|
|
823
|
+
d["granted_to"] = [value for value in (d["granted_to"] or []) if value]
|
|
824
|
+
d["data_access"] = [
|
|
825
|
+
value
|
|
826
|
+
for value in (d["data_access"] or [])
|
|
827
|
+
if value and value.get("mode") is not None and value.get("resource") is not None
|
|
828
|
+
]
|
|
829
|
+
d["annotation_claim"] = "destructiveHint: true"
|
|
830
|
+
d["annotation_provenance"] = _annotation_provenance(r["exposes_evidence"])
|
|
831
|
+
d.pop("exposes_evidence", None)
|
|
832
|
+
rows.append(d)
|
|
833
|
+
return rows
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def annotation_contradictions() -> list[dict]:
|
|
837
|
+
"""Authored WRITES edges on tools hinting ``readOnlyHint: true``.
|
|
838
|
+
|
|
839
|
+
One side is wrong — the server's self-claim or the operator's assertion —
|
|
840
|
+
and the analyzer's job is to make the disagreement visible, not to pick a
|
|
841
|
+
winner. Both sides cite their provenance so the operator can chase the
|
|
842
|
+
stronger evidence (ADR-0006).
|
|
843
|
+
"""
|
|
844
|
+
cypher = """
|
|
845
|
+
MATCH (t:Tool)-[w:WRITES]->(res:Resource)
|
|
846
|
+
WHERE t.read_only_hint = true
|
|
847
|
+
OPTIONAL MATCH (:MCPServer)-[e:EXPOSES]->(t)
|
|
848
|
+
RETURN t.key AS tool_key, t.name AS tool, t.server AS server,
|
|
849
|
+
res.uri AS resource, e.evidence AS exposes_evidence,
|
|
850
|
+
w.source AS source, w.confidence AS confidence, w.evidence AS evidence
|
|
851
|
+
ORDER BY tool_key, resource
|
|
852
|
+
"""
|
|
853
|
+
rows: list[dict] = []
|
|
854
|
+
with session() as s:
|
|
855
|
+
for r in s.run(cypher):
|
|
856
|
+
rows.append(
|
|
857
|
+
{
|
|
858
|
+
"tool_key": r["tool_key"],
|
|
859
|
+
"tool": r["tool"],
|
|
860
|
+
"server": r["server"],
|
|
861
|
+
"resource": r["resource"],
|
|
862
|
+
"annotation_claim": "readOnlyHint: true",
|
|
863
|
+
"annotation_provenance": _annotation_provenance(
|
|
864
|
+
r["exposes_evidence"]
|
|
865
|
+
),
|
|
866
|
+
"authored_claim": f"WRITES {r['resource']}",
|
|
867
|
+
"authored_provenance": _prov(r),
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
return rows
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def blast_radius(node: str) -> dict:
|
|
874
|
+
"""Who/what is affected if a resource or a policy changes (with evidence paths).
|
|
875
|
+
|
|
876
|
+
Every impacted row has the same keys (via/resource/agent/tool_key/tool/mode/path);
|
|
877
|
+
`path` is None when an affected tool has no caller. A tool that touches the
|
|
878
|
+
resource but has no caller is still reported (it is affected).
|
|
879
|
+
|
|
880
|
+
The response carries ``found`` — False when the node argument matches no
|
|
881
|
+
Resource (for a URI) or Policy (otherwise). Without this, a typo'd URI is
|
|
882
|
+
indistinguishable from a real resource that nothing touches; the analyzer's
|
|
883
|
+
negative-truth promise depends on telling the two apart.
|
|
884
|
+
"""
|
|
885
|
+
if is_resource_ref(node):
|
|
886
|
+
uri = normalize_resource_uri(node)
|
|
887
|
+
with session() as s:
|
|
888
|
+
found = s.run(
|
|
889
|
+
"RETURN EXISTS { MATCH (:Resource {uri:$uri}) } AS ok", uri=uri
|
|
890
|
+
).single()["ok"]
|
|
891
|
+
if not found:
|
|
892
|
+
# is_resource_ref is purely lexical: a policy id like 'mailto:foo'
|
|
893
|
+
# or 'urn:bar' looks like a URI scheme. If no Resource matches,
|
|
894
|
+
# try Policy id BEFORE declaring not-found, so URI-shaped policy
|
|
895
|
+
# ids work as `blast-radius` arguments.
|
|
896
|
+
policy_match = s.run(
|
|
897
|
+
"RETURN EXISTS { MATCH (:Policy {id:$node}) } AS ok", node=node
|
|
898
|
+
).single()["ok"]
|
|
899
|
+
if policy_match:
|
|
900
|
+
return _blast_radius_policy(s, node)
|
|
901
|
+
return {"kind": "resource", "node": uri, "found": False, "impacted": []}
|
|
902
|
+
return _blast_radius_resource(s, uri)
|
|
903
|
+
|
|
904
|
+
with session() as s:
|
|
905
|
+
return _blast_radius_policy(s, node)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _blast_radius_resource(s: Session, uri: str) -> dict:
|
|
909
|
+
impacted = []
|
|
910
|
+
for r in s.run(
|
|
911
|
+
"""
|
|
912
|
+
MATCH (r:Resource {uri:$uri})
|
|
913
|
+
OPTIONAL MATCH (t:Tool)-[acc:READS|WRITES]->(r)
|
|
914
|
+
OPTIONAL MATCH (a:Agent)-[:CAN_CALL]->(t)
|
|
915
|
+
RETURN DISTINCT a.id AS agent, t.key AS tool_key, t.name AS tool, type(acc) AS mode,
|
|
916
|
+
acc.source AS source, acc.confidence AS confidence, acc.evidence AS evidence
|
|
917
|
+
ORDER BY agent, tool
|
|
918
|
+
""",
|
|
919
|
+
uri=uri,
|
|
920
|
+
):
|
|
921
|
+
if r["tool"] is None:
|
|
922
|
+
continue # resource exists but nothing reads/writes it
|
|
923
|
+
d = dict(r)
|
|
924
|
+
d["path"] = _radius_path(d["agent"], d["tool"], d["mode"], uri)
|
|
925
|
+
d["provenance"] = _prov(r)
|
|
926
|
+
for k in ("source", "confidence", "evidence"):
|
|
927
|
+
d.pop(k, None)
|
|
928
|
+
impacted.append(d)
|
|
929
|
+
return {"kind": "resource", "node": uri, "found": True, "impacted": impacted}
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _blast_radius_policy(s: Session, node: str) -> dict:
|
|
933
|
+
found = s.run(
|
|
934
|
+
"RETURN EXISTS { MATCH (:Policy {id:$node}) } AS ok", node=node
|
|
935
|
+
).single()["ok"]
|
|
936
|
+
if not found:
|
|
937
|
+
return {"kind": "policy", "node": node, "found": False, "impacted": []}
|
|
938
|
+
impacted = []
|
|
939
|
+
# resources this policy governs, and who reaches them. Bind the tool to
|
|
940
|
+
# the resource first, then OPTIONALLY the agent, so a tool that
|
|
941
|
+
# reads/writes the resource with no CAN_CALL grant is still reported.
|
|
942
|
+
for r in s.run(
|
|
943
|
+
"""
|
|
944
|
+
MATCH (res:Resource)-[:GOVERNED_BY]->(:Policy {id:$node})
|
|
945
|
+
OPTIONAL MATCH (t:Tool)-[acc:READS|WRITES]->(res)
|
|
946
|
+
OPTIONAL MATCH (a:Agent)-[:CAN_CALL]->(t)
|
|
947
|
+
RETURN 'resource' AS via, res.uri AS resource, a.id AS agent,
|
|
948
|
+
t.key AS tool_key, t.name AS tool, type(acc) AS mode,
|
|
949
|
+
acc.source AS source, acc.confidence AS confidence, acc.evidence AS evidence
|
|
950
|
+
ORDER BY resource, agent, tool
|
|
951
|
+
""",
|
|
952
|
+
node=node,
|
|
953
|
+
):
|
|
954
|
+
if r["tool"] is None:
|
|
955
|
+
continue # policy governs a resource, but no tool currently touches it
|
|
956
|
+
d = dict(r)
|
|
957
|
+
d["path"] = _radius_path(d["agent"], d["tool"], d["mode"], d["resource"])
|
|
958
|
+
d["provenance"] = _prov(r)
|
|
959
|
+
for k in ("source", "confidence", "evidence"):
|
|
960
|
+
d.pop(k, None)
|
|
961
|
+
impacted.append(d)
|
|
962
|
+
# tools this policy governs directly, and who can call them
|
|
963
|
+
for r in s.run(
|
|
964
|
+
"""
|
|
965
|
+
MATCH (t:Tool)-[g:GOVERNED_BY]->(:Policy {id:$node})
|
|
966
|
+
OPTIONAL MATCH (a:Agent)-[:CAN_CALL]->(t)
|
|
967
|
+
RETURN 'tool' AS via, null AS resource, a.id AS agent,
|
|
968
|
+
t.key AS tool_key, t.name AS tool, null AS mode,
|
|
969
|
+
g.source AS source, g.confidence AS confidence, g.evidence AS evidence
|
|
970
|
+
ORDER BY tool, agent
|
|
971
|
+
""",
|
|
972
|
+
node=node,
|
|
973
|
+
):
|
|
974
|
+
d = dict(r)
|
|
975
|
+
d["path"] = (
|
|
976
|
+
f"({d['agent']}) -CAN_CALL-> ({d['tool']}) [GOVERNED_BY {node}]"
|
|
977
|
+
if d["agent"]
|
|
978
|
+
else None
|
|
979
|
+
)
|
|
980
|
+
d["provenance"] = _prov(r)
|
|
981
|
+
for k in ("source", "confidence", "evidence"):
|
|
982
|
+
d.pop(k, None)
|
|
983
|
+
impacted.append(d)
|
|
984
|
+
return {"kind": "policy", "node": node, "found": True, "impacted": impacted}
|