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.
@@ -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}