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,166 @@
|
|
|
1
|
+
"""Graph schema: uniqueness constraints.
|
|
2
|
+
|
|
3
|
+
Single-property uniqueness only — these work on Neo4j Community (node-key and
|
|
4
|
+
composite-key constraints are Enterprise-only). Tools therefore carry a synthetic
|
|
5
|
+
``key`` (``"<server>::<tool>"``) so identity is a single indexed property and
|
|
6
|
+
MERGE stays idempotent. No vector index in this MVP.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from toolgraph.graph.driver import BackendConfigurationError, backend_name, session
|
|
14
|
+
|
|
15
|
+
# Version of the physical backend schema this code understands. Bumped only
|
|
16
|
+
# when a table/constraint shape changes incompatibly. IF NOT EXISTS silently
|
|
17
|
+
# skips outdated tables, so without this gate an old database meets new code
|
|
18
|
+
# only as confusing query-time errors.
|
|
19
|
+
BACKEND_SCHEMA_VERSION = 1
|
|
20
|
+
|
|
21
|
+
CONSTRAINTS: list[str] = [
|
|
22
|
+
"CREATE CONSTRAINT mcpserver_name IF NOT EXISTS FOR (s:MCPServer) REQUIRE s.name IS UNIQUE",
|
|
23
|
+
"CREATE CONSTRAINT tool_key IF NOT EXISTS FOR (t:Tool) REQUIRE t.key IS UNIQUE",
|
|
24
|
+
"CREATE CONSTRAINT resource_uri IF NOT EXISTS FOR (r:Resource) REQUIRE r.uri IS UNIQUE",
|
|
25
|
+
"CREATE CONSTRAINT agent_id IF NOT EXISTS FOR (a:Agent) REQUIRE a.id IS UNIQUE",
|
|
26
|
+
"CREATE CONSTRAINT policy_id IF NOT EXISTS FOR (p:Policy) REQUIRE p.id IS UNIQUE",
|
|
27
|
+
"CREATE CONSTRAINT exception_key IF NOT EXISTS FOR (e:ExpectedException) REQUIRE e.key IS UNIQUE",
|
|
28
|
+
"CREATE CONSTRAINT graphmeta_id IF NOT EXISTS FOR (m:GraphMeta) REQUIRE m.id IS UNIQUE",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# ADR-0004: run in the SAME transaction as every successful crawl load and
|
|
32
|
+
# manifest ingest, so generation and graph state can never be observed out of
|
|
33
|
+
# sync. It is an invalidation token, not a semantic version — a crawl of N
|
|
34
|
+
# servers bumps N times. Rejected ingests return before reaching this.
|
|
35
|
+
BUMP_GENERATION = (
|
|
36
|
+
"MERGE (m:GraphMeta {id:'singleton'}) "
|
|
37
|
+
"SET m.instance_id = coalesce(m.instance_id, $graph_instance_id), "
|
|
38
|
+
"m.generation = coalesce(m.generation, 0) + 1, m.updated_at = datetime()"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
ENSURE_GRAPH_META = (
|
|
42
|
+
"MERGE (m:GraphMeta {id:'singleton'}) "
|
|
43
|
+
"SET m.instance_id = coalesce(m.instance_id, $graph_instance_id), "
|
|
44
|
+
"m.generation = coalesce(m.generation, 0)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
LADYBUG_SCHEMA: list[str] = [
|
|
48
|
+
"CREATE NODE TABLE IF NOT EXISTS MCPServer("
|
|
49
|
+
"name STRING PRIMARY KEY, version STRING, transport STRING, endpoint STRING, "
|
|
50
|
+
"last_crawled_at TIMESTAMP)",
|
|
51
|
+
"CREATE NODE TABLE IF NOT EXISTS Tool("
|
|
52
|
+
"key STRING PRIMARY KEY, name STRING, server STRING, description STRING, "
|
|
53
|
+
"input_schema STRING, read_only_hint BOOL, destructive_hint BOOL, "
|
|
54
|
+
"idempotent_hint BOOL, open_world_hint BOOL)",
|
|
55
|
+
"CREATE NODE TABLE IF NOT EXISTS Resource("
|
|
56
|
+
"uri STRING PRIMARY KEY, name STRING, mime_type STRING, description STRING)",
|
|
57
|
+
"CREATE NODE TABLE IF NOT EXISTS Agent(id STRING PRIMARY KEY)",
|
|
58
|
+
"CREATE NODE TABLE IF NOT EXISTS Policy("
|
|
59
|
+
"id STRING PRIMARY KEY, effect STRING, scope STRING, description STRING, "
|
|
60
|
+
"prov_source STRING, prov_confidence STRING, prov_evidence STRING)",
|
|
61
|
+
"CREATE NODE TABLE IF NOT EXISTS ExpectedException("
|
|
62
|
+
"key STRING PRIMARY KEY, agent STRING, tool_key STRING, resource STRING, "
|
|
63
|
+
"policy STRING, reason STRING)",
|
|
64
|
+
"CREATE NODE TABLE IF NOT EXISTS GraphMeta("
|
|
65
|
+
"id STRING PRIMARY KEY, instance_id STRING, generation INT64, updated_at TIMESTAMP, "
|
|
66
|
+
"governance_digest STRING, last_policy_change_id STRING, backend_schema_version INT64)",
|
|
67
|
+
"CREATE REL TABLE IF NOT EXISTS EXPOSES("
|
|
68
|
+
"FROM MCPServer TO Tool, source STRING, confidence STRING, evidence STRING)",
|
|
69
|
+
"CREATE REL TABLE IF NOT EXISTS PROVIDES("
|
|
70
|
+
"FROM MCPServer TO Resource, source STRING, confidence STRING, evidence STRING)",
|
|
71
|
+
"CREATE REL TABLE IF NOT EXISTS CAN_CALL("
|
|
72
|
+
"FROM Agent TO Tool, granted_by STRING, source STRING, confidence STRING, evidence STRING)",
|
|
73
|
+
"CREATE REL TABLE IF NOT EXISTS READS("
|
|
74
|
+
"FROM Tool TO Resource, source STRING, confidence STRING, evidence STRING)",
|
|
75
|
+
"CREATE REL TABLE IF NOT EXISTS WRITES("
|
|
76
|
+
"FROM Tool TO Resource, source STRING, confidence STRING, evidence STRING)",
|
|
77
|
+
"CREATE REL TABLE IF NOT EXISTS GOVERNED_BY("
|
|
78
|
+
"FROM Resource TO Policy, FROM Tool TO Policy, "
|
|
79
|
+
"source STRING, confidence STRING, evidence STRING)",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def exception_key(agent: str, tool_key: str, resource: str | None, policy: str) -> str:
|
|
84
|
+
"""Collision-safe identity; null denotes an exception for any resource."""
|
|
85
|
+
from toolgraph.artifacts import canonical_json_bytes, sha256_bytes
|
|
86
|
+
from toolgraph.uris import normalize_resource_uri
|
|
87
|
+
|
|
88
|
+
resource = normalize_resource_uri(resource) if resource is not None else None
|
|
89
|
+
return "v2:" + sha256_bytes(canonical_json_bytes([agent, tool_key, resource, policy]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def tool_key(server_name: str, tool_name: str) -> str:
|
|
93
|
+
"""Stable identity for a Tool node.
|
|
94
|
+
|
|
95
|
+
Components must not contain the delimiter: (server="a", tool="b::c") and
|
|
96
|
+
(server="a::b", tool="c") would otherwise collapse onto one key and MERGE
|
|
97
|
+
the same node. Model validation enforces this at the crawl boundary; this
|
|
98
|
+
check covers programmatic callers.
|
|
99
|
+
"""
|
|
100
|
+
if "::" in server_name or "::" in tool_name:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"server and tool names must not contain '::' (the tool-key delimiter)"
|
|
103
|
+
)
|
|
104
|
+
return f"{server_name}::{tool_name}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _stored_backend_schema_version(s) -> int | None:
|
|
108
|
+
try:
|
|
109
|
+
record = s.run(
|
|
110
|
+
"MATCH (m:GraphMeta {id:'singleton'}) "
|
|
111
|
+
"RETURN m.backend_schema_version AS version"
|
|
112
|
+
).single()
|
|
113
|
+
except RuntimeError:
|
|
114
|
+
# Fresh embedded database: the GraphMeta table does not exist yet.
|
|
115
|
+
return None
|
|
116
|
+
return record["version"] if record else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _refuse_version_mismatch(stored: int | None) -> None:
|
|
120
|
+
if stored is not None and stored != BACKEND_SCHEMA_VERSION:
|
|
121
|
+
raise BackendConfigurationError(
|
|
122
|
+
f"backend schema version {stored} does not match this build's "
|
|
123
|
+
f"{BACKEND_SCHEMA_VERSION} — "
|
|
124
|
+
+ (
|
|
125
|
+
"upgrade toolgraph to a version that understands it"
|
|
126
|
+
if stored > BACKEND_SCHEMA_VERSION
|
|
127
|
+
else "migrate the database (no automatic migration exists)"
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def init_schema() -> None:
|
|
133
|
+
"""Create all constraints (idempotent) and gate on the schema version."""
|
|
134
|
+
with session() as s:
|
|
135
|
+
# Gate BEFORE running any version-specific DDL: an incompatible client
|
|
136
|
+
# must not mutate the database on its way to the mismatch error.
|
|
137
|
+
_refuse_version_mismatch(_stored_backend_schema_version(s))
|
|
138
|
+
for stmt in LADYBUG_SCHEMA if backend_name() == "ladybug" else CONSTRAINTS:
|
|
139
|
+
s.run(stmt)
|
|
140
|
+
# Backfill databases created before the graph-state contract without
|
|
141
|
+
# invalidating caches: no authored or crawled graph fact changed.
|
|
142
|
+
s.run(ENSURE_GRAPH_META, graph_instance_id=str(uuid4()))
|
|
143
|
+
# Stamp without clobbering: coalesce keeps whatever version a
|
|
144
|
+
# concurrent initializer already wrote; every shipped database has the
|
|
145
|
+
# v1 shape, so a null stamp is always safe to fill.
|
|
146
|
+
s.run(
|
|
147
|
+
"MATCH (m:GraphMeta {id:'singleton'}) "
|
|
148
|
+
"SET m.backend_schema_version = "
|
|
149
|
+
"coalesce(m.backend_schema_version, $version)",
|
|
150
|
+
version=BACKEND_SCHEMA_VERSION,
|
|
151
|
+
)
|
|
152
|
+
# Re-read: if a different-version initializer won the coalesce race,
|
|
153
|
+
# refuse now instead of proceeding against its database.
|
|
154
|
+
_refuse_version_mismatch(_stored_backend_schema_version(s))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def list_constraints() -> list[dict]:
|
|
158
|
+
if backend_name() == "ladybug":
|
|
159
|
+
return [{"name": statement.split("EXISTS", 1)[1].split("(", 1)[0].strip()}
|
|
160
|
+
for statement in LADYBUG_SCHEMA]
|
|
161
|
+
with session() as s:
|
|
162
|
+
return [dict(r) for r in s.run("SHOW CONSTRAINTS")]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def bump_generation(tx) -> None:
|
|
166
|
+
tx.run(BUMP_GENERATION, graph_instance_id=str(uuid4()))
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Deterministic selector surface (ADR-0005): filter/features in, learning out.
|
|
2
|
+
|
|
3
|
+
Three batch-first entry points serve tool selection as a context-engineering
|
|
4
|
+
layer — shrink a candidate catalog deterministically before any relevance
|
|
5
|
+
ranker sees it:
|
|
6
|
+
|
|
7
|
+
- ``rank_features`` — per-candidate facts + a rule-based ``risk_score`` from
|
|
8
|
+
the published fixed table below. No relevance, no learning.
|
|
9
|
+
- ``eligible_tools`` — hard filter with reject reasons and policy-evidence
|
|
10
|
+
paths. Profiles are named rule sets, not learned thresholds; production
|
|
11
|
+
default is ``strict``.
|
|
12
|
+
- ``selection_explain`` — compact human-readable reasons for one (agent, tool).
|
|
13
|
+
|
|
14
|
+
Batch-first because per-candidate ``check_access`` calls scale linearly with
|
|
15
|
+
candidate count — unacceptable on the online selection path. Everything here
|
|
16
|
+
runs a fixed number of queries regardless of N.
|
|
17
|
+
|
|
18
|
+
Hard boundary (ADR-0005): no relevance scoring, no learning. A learned
|
|
19
|
+
consumer may never override a hard reject (NOT_GRANTED, violation, drifted).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from toolgraph.graph.driver import session
|
|
25
|
+
from toolgraph.graph.queries import _deny_evidence_batch, agent_exists, resolve_tool_refs
|
|
26
|
+
|
|
27
|
+
# Hard-filter rule sets (ADR-0005). Reasons are checked in _REASON_PRECEDENCE
|
|
28
|
+
# order; the first applicable reason that the profile rejects wins. These are
|
|
29
|
+
# named rule sets, NOT tunable thresholds — changing them is an ADR-level
|
|
30
|
+
# decision, not a config knob.
|
|
31
|
+
#
|
|
32
|
+
# - strict (production default): only a clean, fully-authored ALLOW passes —
|
|
33
|
+
# granted, no DENY path of any classification, currently exposed, and with
|
|
34
|
+
# authored data-flow (an unmapped tool is exactly the blind spot the graph
|
|
35
|
+
# cannot vouch for, so production fails closed).
|
|
36
|
+
# - review (human-in-the-loop): operator-declared exceptions and unmapped
|
|
37
|
+
# tools pass; violations still reject.
|
|
38
|
+
# - explore (offline eval / sandbox): only resolution failures, missing
|
|
39
|
+
# grants, and drift reject — DENY rows become penalties via rank_features.
|
|
40
|
+
PROFILES: dict[str, frozenset[str]] = {
|
|
41
|
+
"strict": frozenset(
|
|
42
|
+
{
|
|
43
|
+
"TOOL_NOT_FOUND",
|
|
44
|
+
"AMBIGUOUS_TOOL",
|
|
45
|
+
"NOT_GRANTED",
|
|
46
|
+
"DENY_VIOLATION",
|
|
47
|
+
"DENY_GOVERNED",
|
|
48
|
+
"DRIFTED",
|
|
49
|
+
"UNMAPPED",
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
"review": frozenset(
|
|
53
|
+
{"TOOL_NOT_FOUND", "AMBIGUOUS_TOOL", "NOT_GRANTED", "DENY_VIOLATION", "DRIFTED"}
|
|
54
|
+
),
|
|
55
|
+
"explore": frozenset({"TOOL_NOT_FOUND", "AMBIGUOUS_TOOL", "NOT_GRANTED", "DRIFTED"}),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
DEFAULT_PROFILE = "strict"
|
|
59
|
+
|
|
60
|
+
# Batch bound, mirroring the control-plan limit: the selector is batch-first,
|
|
61
|
+
# but an unbounded candidate list still UNWINDs as one query parameter. Lives
|
|
62
|
+
# here so every surface — CLI, MCP server, library callers — is bounded.
|
|
63
|
+
MAX_CANDIDATES = 4096
|
|
64
|
+
|
|
65
|
+
# Drift rejects in EVERY profile (the report's regression criteria): a tool no
|
|
66
|
+
# server currently exposes cannot be called, so passing it is never useful.
|
|
67
|
+
_REASON_PRECEDENCE = (
|
|
68
|
+
"TOOL_NOT_FOUND",
|
|
69
|
+
"AMBIGUOUS_TOOL",
|
|
70
|
+
"NOT_GRANTED",
|
|
71
|
+
"DENY_VIOLATION",
|
|
72
|
+
"DENY_GOVERNED",
|
|
73
|
+
"DRIFTED",
|
|
74
|
+
"UNMAPPED",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _risk_score(feature: dict) -> float | None:
|
|
79
|
+
"""Rule-based risk from the published fixed table (ADR-0005 / report).
|
|
80
|
+
|
|
81
|
+
First match wins, top-down. This is a fact-derived feature, not a learned
|
|
82
|
+
weight — consumers may add penalties on top but never subtract a hard
|
|
83
|
+
reject. ``None`` when the candidate didn't resolve to exactly one tool
|
|
84
|
+
(no facts to score).
|
|
85
|
+
|
|
86
|
+
1.0 DENY path with classification=violation
|
|
87
|
+
0.8 drifted (no live EXPOSES edge)
|
|
88
|
+
0.6 unmapped (no authored READS/WRITES)
|
|
89
|
+
0.4 unbacked load-bearing edge in the tool's evidence chain
|
|
90
|
+
0.2 DENY path fully covered by operator-declared exceptions
|
|
91
|
+
0.0 otherwise
|
|
92
|
+
"""
|
|
93
|
+
if not feature["found"] or feature["ambiguous"]:
|
|
94
|
+
return None
|
|
95
|
+
if feature["classification"] == "violation":
|
|
96
|
+
return 1.0
|
|
97
|
+
if feature["is_drifted"]:
|
|
98
|
+
return 0.8
|
|
99
|
+
if feature["is_unmapped"]:
|
|
100
|
+
return 0.6
|
|
101
|
+
if feature["has_unbacked_edges"]:
|
|
102
|
+
return 0.4
|
|
103
|
+
if feature["classification"] == "authorized_but_governed":
|
|
104
|
+
return 0.2
|
|
105
|
+
return 0.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_all(s, refs: list[str]) -> dict[str, list[str]]:
|
|
109
|
+
"""Use the same bounded resolution as ingest and single-tool queries."""
|
|
110
|
+
return resolve_tool_refs(s, refs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _tool_facts(s, keys: list[str], agent: str) -> dict[str, dict]:
|
|
114
|
+
"""Batch per-tool facts for resolved keys: grant, drift, mapping, evidence
|
|
115
|
+
coverage, and the #14 annotation self-claims (which enrich features but
|
|
116
|
+
never gate by themselves — they are crawled/medium claims, ADR-0006)."""
|
|
117
|
+
rows = s.run(
|
|
118
|
+
"""
|
|
119
|
+
MATCH (t:Tool) WHERE t.key IN $keys
|
|
120
|
+
RETURN t.key AS key,
|
|
121
|
+
t.name AS name, t.server AS server,
|
|
122
|
+
t.read_only_hint AS read_only_hint,
|
|
123
|
+
t.destructive_hint AS destructive_hint,
|
|
124
|
+
t.idempotent_hint AS idempotent_hint,
|
|
125
|
+
t.open_world_hint AS open_world_hint,
|
|
126
|
+
EXISTS { MATCH (:Agent {id:$agent})-[:CAN_CALL]->(t) } AS permitted,
|
|
127
|
+
NOT EXISTS { MATCH (:MCPServer)-[:EXPOSES]->(t) } AS is_drifted,
|
|
128
|
+
NOT EXISTS { MATCH (t)-[:READS|WRITES]->(:Resource) } AS is_unmapped,
|
|
129
|
+
EXISTS {
|
|
130
|
+
MATCH (t)-[r:READS|WRITES|GOVERNED_BY]->()
|
|
131
|
+
WHERE r.evidence IS NULL OR trim(r.evidence) = ''
|
|
132
|
+
} OR EXISTS {
|
|
133
|
+
MATCH (t)-[:READS|WRITES]->(:Resource)-[g:GOVERNED_BY]->()
|
|
134
|
+
WHERE g.evidence IS NULL OR trim(g.evidence) = ''
|
|
135
|
+
} AS has_unbacked_edges
|
|
136
|
+
""",
|
|
137
|
+
keys=keys,
|
|
138
|
+
agent=agent,
|
|
139
|
+
)
|
|
140
|
+
return {r["key"]: dict(r) for r in rows}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _classify(deny_rows: list[dict]) -> str | None:
|
|
144
|
+
"""Worst-case classification across a tool's deny paths: any violation row
|
|
145
|
+
dominates; all-exception rows collapse to authorized_but_governed."""
|
|
146
|
+
if not deny_rows:
|
|
147
|
+
return None
|
|
148
|
+
if any(d.get("classification") == "violation" for d in deny_rows):
|
|
149
|
+
return "violation"
|
|
150
|
+
return "authorized_but_governed"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def rank_features(agent: str, candidates: list[str]) -> dict:
|
|
154
|
+
"""Batch selection features for every candidate, in input order.
|
|
155
|
+
|
|
156
|
+
``agent_found: false`` means selection itself should abort (a context
|
|
157
|
+
construction error, not an empty result) — features come back empty.
|
|
158
|
+
|
|
159
|
+
Each feature row keeps the input ref as its stable id (``candidate``) and
|
|
160
|
+
carries: resolution (found/ambiguous/tool_key), grant (``permitted``),
|
|
161
|
+
verdict (mirrors ``check_access``), worst-case deny ``classification``
|
|
162
|
+
with the full ``deny_paths``, drift / mapping / evidence-coverage facts,
|
|
163
|
+
the four annotation self-claims (ADR-0006), and the rule-based
|
|
164
|
+
``risk_score`` (see ``_risk_score`` for the fixed table).
|
|
165
|
+
"""
|
|
166
|
+
if len(candidates) > MAX_CANDIDATES:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"{len(candidates)} candidates exceed the {MAX_CANDIDATES} limit"
|
|
169
|
+
)
|
|
170
|
+
if not agent_exists(agent):
|
|
171
|
+
return {"agent": agent, "agent_found": False, "features": []}
|
|
172
|
+
|
|
173
|
+
with session() as s:
|
|
174
|
+
resolved = _resolve_all(s, candidates)
|
|
175
|
+
unique_keys = sorted(
|
|
176
|
+
{keys[0] for keys in resolved.values() if len(keys) == 1}
|
|
177
|
+
)
|
|
178
|
+
facts = _tool_facts(s, unique_keys, agent)
|
|
179
|
+
deny = _deny_evidence_batch(s, unique_keys, agent=agent)
|
|
180
|
+
|
|
181
|
+
features: list[dict] = []
|
|
182
|
+
for ref in candidates: # input order is the contract — never re-sorted
|
|
183
|
+
keys = resolved.get(ref, [])
|
|
184
|
+
row: dict = {
|
|
185
|
+
"candidate": ref,
|
|
186
|
+
"tool_key": None,
|
|
187
|
+
"found": bool(keys),
|
|
188
|
+
"ambiguous": len(keys) > 1,
|
|
189
|
+
"permitted": None,
|
|
190
|
+
"verdict": None,
|
|
191
|
+
"classification": None,
|
|
192
|
+
"deny_paths": [],
|
|
193
|
+
"is_drifted": None,
|
|
194
|
+
"is_unmapped": None,
|
|
195
|
+
"has_unbacked_edges": None,
|
|
196
|
+
"read_only_hint": None,
|
|
197
|
+
"destructive_hint": None,
|
|
198
|
+
"idempotent_hint": None,
|
|
199
|
+
"open_world_hint": None,
|
|
200
|
+
}
|
|
201
|
+
if not keys:
|
|
202
|
+
row["verdict"] = "TOOL_NOT_FOUND"
|
|
203
|
+
elif len(keys) > 1:
|
|
204
|
+
# Never auto-resolved: the consumer must re-submit the
|
|
205
|
+
# server-qualified key (regression criterion #1).
|
|
206
|
+
row["verdict"] = "AMBIGUOUS_TOOL"
|
|
207
|
+
row["candidates"] = keys
|
|
208
|
+
elif keys[0] not in facts:
|
|
209
|
+
# Tool deleted between resolution and the facts read — Neo4j is
|
|
210
|
+
# read-committed, so the two batch queries can straddle a
|
|
211
|
+
# concurrent crawl/ingest (the same window check_access closes
|
|
212
|
+
# with its meta-is-None branch). Report the truth signal instead
|
|
213
|
+
# of crashing the whole batch (Codex review).
|
|
214
|
+
row["found"] = False
|
|
215
|
+
row["verdict"] = "TOOL_NOT_FOUND"
|
|
216
|
+
else:
|
|
217
|
+
key = keys[0]
|
|
218
|
+
f = facts[key]
|
|
219
|
+
deny_rows = deny[key]
|
|
220
|
+
row["tool_key"] = key
|
|
221
|
+
row["permitted"] = f["permitted"]
|
|
222
|
+
row["classification"] = _classify(deny_rows)
|
|
223
|
+
row["deny_paths"] = deny_rows
|
|
224
|
+
row["is_drifted"] = f["is_drifted"]
|
|
225
|
+
row["is_unmapped"] = f["is_unmapped"]
|
|
226
|
+
row["has_unbacked_edges"] = f["has_unbacked_edges"]
|
|
227
|
+
for hint in (
|
|
228
|
+
"read_only_hint",
|
|
229
|
+
"destructive_hint",
|
|
230
|
+
"idempotent_hint",
|
|
231
|
+
"open_world_hint",
|
|
232
|
+
):
|
|
233
|
+
row[hint] = f[hint]
|
|
234
|
+
if not f["permitted"]:
|
|
235
|
+
row["verdict"] = "NOT_GRANTED"
|
|
236
|
+
elif deny_rows:
|
|
237
|
+
row["verdict"] = "DENY"
|
|
238
|
+
else:
|
|
239
|
+
row["verdict"] = "ALLOW"
|
|
240
|
+
row["risk_score"] = _risk_score(row)
|
|
241
|
+
features.append(row)
|
|
242
|
+
|
|
243
|
+
return {"agent": agent, "agent_found": True, "features": features}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _applicable_reasons(feature: dict) -> list[str]:
|
|
247
|
+
"""All reject conditions this feature row triggers, in precedence order."""
|
|
248
|
+
reasons: list[str] = []
|
|
249
|
+
if not feature["found"]:
|
|
250
|
+
reasons.append("TOOL_NOT_FOUND")
|
|
251
|
+
return reasons # nothing else is knowable about an unresolved ref
|
|
252
|
+
if feature["ambiguous"]:
|
|
253
|
+
reasons.append("AMBIGUOUS_TOOL")
|
|
254
|
+
return reasons
|
|
255
|
+
if not feature["permitted"]:
|
|
256
|
+
reasons.append("NOT_GRANTED")
|
|
257
|
+
if feature["classification"] == "violation":
|
|
258
|
+
reasons.append("DENY_VIOLATION")
|
|
259
|
+
elif feature["classification"] == "authorized_but_governed":
|
|
260
|
+
reasons.append("DENY_GOVERNED")
|
|
261
|
+
if feature["is_drifted"]:
|
|
262
|
+
reasons.append("DRIFTED")
|
|
263
|
+
if feature["is_unmapped"]:
|
|
264
|
+
reasons.append("UNMAPPED")
|
|
265
|
+
return reasons
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def filter_features(ranked: dict, profile: str = DEFAULT_PROFILE) -> dict:
|
|
269
|
+
"""Pure hard filter over an already-computed ``rank_features`` result.
|
|
270
|
+
|
|
271
|
+
Runs no queries: producers that need both the filter verdict and the
|
|
272
|
+
feature rows (policy bundle, preflight ``--features``, explain) call
|
|
273
|
+
``rank_features`` once and filter its output, instead of paying the full
|
|
274
|
+
graph evaluation twice inside the generation-retry bracket.
|
|
275
|
+
"""
|
|
276
|
+
if profile not in PROFILES:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
|
|
279
|
+
)
|
|
280
|
+
agent = ranked["agent"]
|
|
281
|
+
if not ranked["agent_found"]:
|
|
282
|
+
return {
|
|
283
|
+
"agent": agent,
|
|
284
|
+
"agent_found": False,
|
|
285
|
+
"profile": profile,
|
|
286
|
+
"eligible": [],
|
|
287
|
+
"rejected": [],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
rejects = PROFILES[profile]
|
|
291
|
+
eligible: list[str] = []
|
|
292
|
+
rejected: list[dict] = []
|
|
293
|
+
for feature in ranked["features"]:
|
|
294
|
+
reason = next(
|
|
295
|
+
(r for r in _applicable_reasons(feature) if r in rejects), None
|
|
296
|
+
)
|
|
297
|
+
if reason is None:
|
|
298
|
+
eligible.append(feature["tool_key"])
|
|
299
|
+
continue
|
|
300
|
+
row = {
|
|
301
|
+
"candidate": feature["candidate"],
|
|
302
|
+
"tool_key": feature["tool_key"],
|
|
303
|
+
"reason": reason,
|
|
304
|
+
}
|
|
305
|
+
if reason == "AMBIGUOUS_TOOL":
|
|
306
|
+
row["candidates"] = feature["candidates"]
|
|
307
|
+
if reason in ("DENY_VIOLATION", "DENY_GOVERNED"):
|
|
308
|
+
wanted = (
|
|
309
|
+
"violation" if reason == "DENY_VIOLATION" else "authorized_but_governed"
|
|
310
|
+
)
|
|
311
|
+
row["paths"] = [
|
|
312
|
+
d["path"]
|
|
313
|
+
for d in feature["deny_paths"]
|
|
314
|
+
if d.get("classification") == wanted
|
|
315
|
+
]
|
|
316
|
+
rejected.append(row)
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
"agent": agent,
|
|
320
|
+
"agent_found": True,
|
|
321
|
+
"profile": profile,
|
|
322
|
+
"eligible": eligible,
|
|
323
|
+
"rejected": rejected,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def eligible_tools(
|
|
328
|
+
agent: str, candidates: list[str], profile: str = DEFAULT_PROFILE
|
|
329
|
+
) -> dict:
|
|
330
|
+
"""Hard filter with reject reasons and policy-evidence paths (ADR-0005).
|
|
331
|
+
|
|
332
|
+
``eligible`` holds resolved tool keys in candidate input order; every
|
|
333
|
+
rejected row keeps the input ref (``candidate``), the winning ``reason``
|
|
334
|
+
(first by precedence among the profile's reject set), and — for DENY
|
|
335
|
+
reasons — the graph paths that prove it. A learned consumer may rerank
|
|
336
|
+
``eligible`` but may never resurrect a rejected row.
|
|
337
|
+
"""
|
|
338
|
+
if profile not in PROFILES:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
|
|
341
|
+
)
|
|
342
|
+
return filter_features(rank_features(agent, candidates), profile)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def selection_explain(
|
|
346
|
+
agent: str, tool: str, profile: str = DEFAULT_PROFILE
|
|
347
|
+
) -> dict:
|
|
348
|
+
"""Compact human-readable reasons for one (agent, tool) — a view over the
|
|
349
|
+
same facts ``rank_features`` returns, for operators and end users."""
|
|
350
|
+
if profile not in PROFILES:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
|
|
353
|
+
)
|
|
354
|
+
ranked = rank_features(agent, [tool])
|
|
355
|
+
if not ranked["agent_found"]:
|
|
356
|
+
return {
|
|
357
|
+
"agent": agent,
|
|
358
|
+
"tool": tool,
|
|
359
|
+
"tool_key": None,
|
|
360
|
+
"agent_found": False,
|
|
361
|
+
"profile": profile,
|
|
362
|
+
"decision": "rejected",
|
|
363
|
+
"reasons": [f"agent {agent!r} not found in the graph — selection aborted"],
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
feature = ranked["features"][0]
|
|
367
|
+
filtered = filter_features(ranked, profile)
|
|
368
|
+
decision = "eligible" if filtered["eligible"] else "rejected"
|
|
369
|
+
|
|
370
|
+
reasons: list[str] = []
|
|
371
|
+
if not feature["found"]:
|
|
372
|
+
reasons.append(f"no tool named {tool!r} in the graph")
|
|
373
|
+
elif feature["ambiguous"]:
|
|
374
|
+
reasons.append(
|
|
375
|
+
f"bare name matches multiple servers {feature['candidates']} — "
|
|
376
|
+
f"pass the server-qualified key; never auto-selected"
|
|
377
|
+
)
|
|
378
|
+
else:
|
|
379
|
+
reasons.append(
|
|
380
|
+
"agent has CAN_CALL grant"
|
|
381
|
+
if feature["permitted"]
|
|
382
|
+
else "agent has no CAN_CALL grant"
|
|
383
|
+
)
|
|
384
|
+
if not feature["deny_paths"]:
|
|
385
|
+
reasons.append("no DENY policy path found")
|
|
386
|
+
for d in feature["deny_paths"]:
|
|
387
|
+
label = d.get("classification", "deny")
|
|
388
|
+
line = f"DENY path [{label}]: {d['path']}"
|
|
389
|
+
if d.get("exception_reason"):
|
|
390
|
+
line += f" (operator: {d['exception_reason']})"
|
|
391
|
+
reasons.append(line)
|
|
392
|
+
reasons.append(
|
|
393
|
+
"tool has drifted: no server currently exposes it"
|
|
394
|
+
if feature["is_drifted"]
|
|
395
|
+
else "tool is currently exposed by a crawled server"
|
|
396
|
+
)
|
|
397
|
+
reasons.append(
|
|
398
|
+
"no authored READS/WRITES — data-flow effects unclassified"
|
|
399
|
+
if feature["is_unmapped"]
|
|
400
|
+
else "data-flow effects are authored (READS/WRITES present)"
|
|
401
|
+
)
|
|
402
|
+
reasons.append(
|
|
403
|
+
"load-bearing edge(s) missing evidence pointers"
|
|
404
|
+
if feature["has_unbacked_edges"]
|
|
405
|
+
else "all load-bearing edges carry evidence"
|
|
406
|
+
)
|
|
407
|
+
for hint in ("read_only_hint", "destructive_hint", "idempotent_hint", "open_world_hint"):
|
|
408
|
+
if feature[hint] is not None:
|
|
409
|
+
reasons.append(
|
|
410
|
+
f"server self-claim (crawled/medium): {hint}={feature[hint]}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
"agent": agent,
|
|
415
|
+
"tool": tool,
|
|
416
|
+
"tool_key": feature["tool_key"],
|
|
417
|
+
"agent_found": True,
|
|
418
|
+
"found": feature["found"],
|
|
419
|
+
"profile": profile,
|
|
420
|
+
"decision": decision,
|
|
421
|
+
"risk_score": feature["risk_score"],
|
|
422
|
+
"reasons": reasons,
|
|
423
|
+
}
|
toolgraph/graph/store.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Domain-level graph ports used by policy services.
|
|
2
|
+
|
|
3
|
+
No Cypher or backend result object crosses this boundary. The current runtime
|
|
4
|
+
implementation delegates to the established query/selector services; backend
|
|
5
|
+
adapters remain responsible for DDL, transactions, and row normalization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from toolgraph.graph import queries, selector
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GraphReader(Protocol):
|
|
16
|
+
def state(self) -> queries.GraphState: ...
|
|
17
|
+
|
|
18
|
+
def exposed_tool_contracts(self) -> list[dict]: ...
|
|
19
|
+
|
|
20
|
+
def eligible_tools(self, agent: str, candidates: list[str], profile: str) -> dict: ...
|
|
21
|
+
|
|
22
|
+
def rank_features(self, agent: str, candidates: list[str]) -> dict: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EvaluatingGraphReader(GraphReader, Protocol):
|
|
26
|
+
# Optional: adapters that can evaluate once and filter in memory expose
|
|
27
|
+
# this combined form; build_policy_bundle prefers it and falls back to the
|
|
28
|
+
# eligible_tools + rank_features pair for adapters that do not.
|
|
29
|
+
def evaluate(self, agent: str, candidates: list[str], profile: str) -> dict: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RuntimeGraphStore:
|
|
33
|
+
"""Backend-neutral policy read service over the configured graph adapter."""
|
|
34
|
+
|
|
35
|
+
def state(self) -> queries.GraphState:
|
|
36
|
+
return queries.graph_state()
|
|
37
|
+
|
|
38
|
+
def exposed_tool_contracts(self) -> list[dict]:
|
|
39
|
+
return queries.exposed_tool_contracts()
|
|
40
|
+
|
|
41
|
+
def eligible_tools(self, agent: str, candidates: list[str], profile: str) -> dict:
|
|
42
|
+
return selector.eligible_tools(agent, candidates, profile=profile)
|
|
43
|
+
|
|
44
|
+
def rank_features(self, agent: str, candidates: list[str]) -> dict:
|
|
45
|
+
return selector.rank_features(agent, candidates)
|
|
46
|
+
|
|
47
|
+
def evaluate(self, agent: str, candidates: list[str], profile: str) -> dict:
|
|
48
|
+
"""One graph evaluation; the hard filter is pure over the ranked rows."""
|
|
49
|
+
ranked = selector.rank_features(agent, candidates)
|
|
50
|
+
if not ranked["agent_found"]:
|
|
51
|
+
return {"agent_found": False, "features": [], "filtered": None}
|
|
52
|
+
filtered = selector.filter_features(ranked, profile)
|
|
53
|
+
return {
|
|
54
|
+
"agent_found": True,
|
|
55
|
+
"features": ranked["features"],
|
|
56
|
+
"filtered": filtered,
|
|
57
|
+
}
|
|
File without changes
|