toolgraph 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolgraph/__init__.py +3 -0
- toolgraph/artifact_safety.py +97 -0
- toolgraph/artifacts.py +122 -0
- toolgraph/cli.py +951 -0
- toolgraph/config.py +106 -0
- toolgraph/control_plan.py +494 -0
- toolgraph/crawler/__init__.py +0 -0
- toolgraph/crawler/client.py +120 -0
- toolgraph/crawler/crawl.py +103 -0
- toolgraph/crawler/transports.py +60 -0
- toolgraph/graph/__init__.py +0 -0
- toolgraph/graph/driver.py +275 -0
- toolgraph/graph/loader.py +280 -0
- toolgraph/graph/queries.py +984 -0
- toolgraph/graph/schema.py +166 -0
- toolgraph/graph/selector.py +423 -0
- toolgraph/graph/store.py +57 -0
- toolgraph/manifest/__init__.py +0 -0
- toolgraph/manifest/ingest.py +542 -0
- toolgraph/manifest/parser.py +26 -0
- toolgraph/models.py +192 -0
- toolgraph/policy_bundle.py +212 -0
- toolgraph/policy_review.py +97 -0
- toolgraph/preflight.py +77 -0
- toolgraph/quickstart/governance.yaml +28 -0
- toolgraph/quickstart/policy_gateway_server.py +31 -0
- toolgraph/quickstart/servers.yaml +5 -0
- toolgraph/redaction.py +82 -0
- toolgraph/review_candidates.py +579 -0
- toolgraph/server/__init__.py +0 -0
- toolgraph/server/app.py +306 -0
- toolgraph/uris.py +64 -0
- toolgraph/yaml_loader.py +68 -0
- toolgraph-0.0.1.dist-info/METADATA +674 -0
- toolgraph-0.0.1.dist-info/RECORD +38 -0
- toolgraph-0.0.1.dist-info/WHEEL +4 -0
- toolgraph-0.0.1.dist-info/entry_points.txt +2 -0
- toolgraph-0.0.1.dist-info/licenses/LICENSE +191 -0
toolgraph/models.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Pydantic models: crawl inputs/outputs and authored-governance records.
|
|
2
|
+
|
|
3
|
+
Two families:
|
|
4
|
+
- Crawler config + factual crawl results.
|
|
5
|
+
- Authored governance (policies, ACL grants, data access) — used by manifest
|
|
6
|
+
ingest, defined here so the schema lives in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
14
|
+
|
|
15
|
+
Transport = Literal["stdio", "streamable-http", "sse"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _no_key_delimiter(kind: str, value: str) -> str:
|
|
19
|
+
# "::" is the Tool-key delimiter ("<server>::<tool>"). A component that
|
|
20
|
+
# contains it collapses distinct identities onto one key — e.g.
|
|
21
|
+
# (server="a", tool="b::c") and (server="a::b", tool="c") MERGE the same
|
|
22
|
+
# node — and the ecosystem bundle schema requires exactly one delimiter.
|
|
23
|
+
if "::" in value:
|
|
24
|
+
raise ValueError(f"{kind} must not contain '::' (the tool-key delimiter)")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StrictInputModel(BaseModel):
|
|
29
|
+
"""Base for operator-authored input: unknown fields are always errors."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- crawler config (servers.yaml) ---------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ServerSpec(StrictInputModel):
|
|
38
|
+
"""One MCP server to crawl."""
|
|
39
|
+
|
|
40
|
+
name: str | None = None # override; else taken from serverInfo.name
|
|
41
|
+
transport: Transport = "stdio"
|
|
42
|
+
|
|
43
|
+
@field_validator("name")
|
|
44
|
+
@classmethod
|
|
45
|
+
def _name_is_key_safe(cls, value: str | None) -> str | None:
|
|
46
|
+
return value if value is None else _no_key_delimiter("server name", value)
|
|
47
|
+
# stdio
|
|
48
|
+
command: str | None = None
|
|
49
|
+
args: list[str] = Field(default_factory=list)
|
|
50
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
51
|
+
# http / sse
|
|
52
|
+
url: str | None = None
|
|
53
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ServersConfig(StrictInputModel):
|
|
57
|
+
servers: list[ServerSpec] = Field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- factual crawl results (EXPOSES / PROVIDES) --------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ToolRecord(BaseModel):
|
|
64
|
+
name: str
|
|
65
|
+
description: str | None = None
|
|
66
|
+
|
|
67
|
+
@field_validator("name")
|
|
68
|
+
@classmethod
|
|
69
|
+
def _name_is_key_safe(cls, value: str) -> str:
|
|
70
|
+
return _no_key_delimiter("tool name", value)
|
|
71
|
+
input_schema: dict | None = None
|
|
72
|
+
# MCP tool annotations (ADR-0006): the server's self-claims about its own
|
|
73
|
+
# behavior. Crawlable, but unverified by anyone — they surface with
|
|
74
|
+
# ``confidence: medium``, never ``high`` like EXPOSES/PROVIDES.
|
|
75
|
+
read_only_hint: bool | None = None
|
|
76
|
+
destructive_hint: bool | None = None
|
|
77
|
+
idempotent_hint: bool | None = None
|
|
78
|
+
open_world_hint: bool | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ResourceRecord(BaseModel):
|
|
82
|
+
uri: str
|
|
83
|
+
name: str | None = None
|
|
84
|
+
mime_type: str | None = None
|
|
85
|
+
description: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CrawlResult(BaseModel):
|
|
89
|
+
server_name: str
|
|
90
|
+
server_version: str | None = None
|
|
91
|
+
|
|
92
|
+
@field_validator("server_name")
|
|
93
|
+
@classmethod
|
|
94
|
+
def _server_name_is_key_safe(cls, value: str) -> str:
|
|
95
|
+
return _no_key_delimiter("server name", value)
|
|
96
|
+
transport: Transport = "stdio"
|
|
97
|
+
endpoint: str | None = None # command line or url, for provenance
|
|
98
|
+
tools: list[ToolRecord] = Field(default_factory=list)
|
|
99
|
+
resources: list[ResourceRecord] = Field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- authored governance (governance.yaml) -------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Provenance — per-edge metadata so a reader can tell crawled facts from
|
|
106
|
+
# operator assertions, and find the supporting evidence for each assertion.
|
|
107
|
+
# Default for authored edges is operator_asserted/high with no evidence link;
|
|
108
|
+
# the loader sets crawled/high/<endpoint> on EXPOSES/PROVIDES.
|
|
109
|
+
ProvenanceSource = Literal["crawled", "operator_asserted", "inferred"]
|
|
110
|
+
ProvenanceConfidence = Literal["high", "medium", "low"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Provenance(StrictInputModel):
|
|
114
|
+
"""Where an edge came from and how strong the claim is."""
|
|
115
|
+
|
|
116
|
+
source: ProvenanceSource = "operator_asserted"
|
|
117
|
+
confidence: ProvenanceConfidence = "high"
|
|
118
|
+
# free-text pointer: file:line, URL, commit SHA, ticket id, runtime trace.
|
|
119
|
+
evidence: str | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Policy(StrictInputModel):
|
|
123
|
+
# ``extra='forbid'`` catches old manifests that still carry a node-level
|
|
124
|
+
# ``provenance:`` block (the pre-completion-round shape) with a clear
|
|
125
|
+
# Pydantic error pointing at the field, instead of silently ignoring it.
|
|
126
|
+
# The replacement lives on GovernedByBinding (per-edge provenance).
|
|
127
|
+
id: str
|
|
128
|
+
effect: Literal["ALLOW", "DENY"]
|
|
129
|
+
scope: str | None = None
|
|
130
|
+
description: str | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AccessGrant(StrictInputModel):
|
|
134
|
+
"""Agent -[:CAN_CALL]-> Tool. Tool referenced as '<server>::<tool>' or '<tool>'."""
|
|
135
|
+
|
|
136
|
+
agent: str
|
|
137
|
+
tool: str
|
|
138
|
+
granted_by: str | None = None
|
|
139
|
+
provenance: Provenance | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class DataAccess(StrictInputModel):
|
|
143
|
+
"""Tool -[:READS|WRITES]-> Resource."""
|
|
144
|
+
|
|
145
|
+
tool: str
|
|
146
|
+
resource: str # resource uri
|
|
147
|
+
mode: Literal["READS", "WRITES"]
|
|
148
|
+
provenance: Provenance | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ExpectedException(StrictInputModel):
|
|
152
|
+
"""Operator-declared exception: this (agent, tool, resource?, policy) reach
|
|
153
|
+
is intentional — surface it as ``authorized_but_governed`` rather than
|
|
154
|
+
``violation`` so reviewers don't drown in noise on by-design grants."""
|
|
155
|
+
|
|
156
|
+
agent: str
|
|
157
|
+
tool: str # '<server>::<tool>' or bare name (same resolution rules as grants)
|
|
158
|
+
policy: str
|
|
159
|
+
resource: str | None = None # narrow to a specific resource; None = any
|
|
160
|
+
reason: str | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class GovernedByBinding(StrictInputModel):
|
|
164
|
+
"""One (node, policy) binding with optional evidence.
|
|
165
|
+
|
|
166
|
+
Lets authors cite WHY this resource (or tool) is bound to this policy —
|
|
167
|
+
e.g. a config file, a runbook, a compliance ticket. Bare strings in
|
|
168
|
+
``governed_by`` lists are still accepted and lift to this shape with
|
|
169
|
+
``provenance=None``.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
policy: str
|
|
173
|
+
provenance: Provenance | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Governance(StrictInputModel):
|
|
177
|
+
agents: list[str] = Field(default_factory=list)
|
|
178
|
+
policies: list[Policy] = Field(default_factory=list)
|
|
179
|
+
grants: list[AccessGrant] = Field(default_factory=list)
|
|
180
|
+
data_access: list[DataAccess] = Field(default_factory=list)
|
|
181
|
+
# node id -> list of bindings. Each item is either a bare policy_id string
|
|
182
|
+
# (backward-compatible, no provenance) or a GovernedByBinding object with
|
|
183
|
+
# per-binding source citation. node id is a resource uri or '<server>::<tool>'.
|
|
184
|
+
governed_by: dict[str, list[str | GovernedByBinding]] = Field(default_factory=dict)
|
|
185
|
+
expected_exceptions: list[ExpectedException] = Field(default_factory=list)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def edge_provenance_props(p: Provenance | None) -> dict:
|
|
189
|
+
"""Default to operator_asserted/high when authoring code omits provenance."""
|
|
190
|
+
if p is None:
|
|
191
|
+
return {"source": "operator_asserted", "confidence": "high", "evidence": None}
|
|
192
|
+
return p.model_dump()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Portable, product-neutral policy bundle compiler.
|
|
2
|
+
|
|
3
|
+
The bundle is a control-plane artifact. It contains deterministic policy
|
|
4
|
+
decisions and crawl contract fingerprints, but no proxy/runtime behavior.
|
|
5
|
+
memtomem-stm is the first reference consumer; independent gateways can consume
|
|
6
|
+
the same schema without importing Toolgraph Python code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from toolgraph.artifacts import (
|
|
16
|
+
canonical_json_bytes,
|
|
17
|
+
rfc3339_offset_error,
|
|
18
|
+
sha256_bytes,
|
|
19
|
+
)
|
|
20
|
+
from toolgraph.graph import queries, selector
|
|
21
|
+
from toolgraph.graph.store import GraphReader, RuntimeGraphStore
|
|
22
|
+
from toolgraph.artifact_safety import safe_artifact, validate_identity
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = 1
|
|
25
|
+
KIND = "toolgraph.policy-bundle"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PolicyBundleError(RuntimeError):
|
|
29
|
+
"""The graph cannot produce a trustworthy policy bundle."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tool_contract_digest(contract: dict[str, Any]) -> str:
|
|
33
|
+
fields = {
|
|
34
|
+
key: contract.get(key)
|
|
35
|
+
for key in (
|
|
36
|
+
"server",
|
|
37
|
+
"name",
|
|
38
|
+
"description",
|
|
39
|
+
"input_schema",
|
|
40
|
+
"read_only_hint",
|
|
41
|
+
"destructive_hint",
|
|
42
|
+
"idempotent_hint",
|
|
43
|
+
"open_world_hint",
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return sha256_bytes(canonical_json_bytes(fields))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _catalog_digest(contracts: list[dict[str, Any]]) -> str:
|
|
50
|
+
rows = [
|
|
51
|
+
{
|
|
52
|
+
"tool_key": row["tool_key"],
|
|
53
|
+
"tool_contract_digest": tool_contract_digest(row),
|
|
54
|
+
}
|
|
55
|
+
for row in contracts
|
|
56
|
+
]
|
|
57
|
+
return sha256_bytes(canonical_json_bytes(rows))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_policy_bundle(
|
|
61
|
+
*,
|
|
62
|
+
agent: str,
|
|
63
|
+
profile: str = selector.DEFAULT_PROFILE,
|
|
64
|
+
created_at: datetime | None = None,
|
|
65
|
+
store: GraphReader | None = None,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Compile every currently exposed qualified tool under one policy profile."""
|
|
68
|
+
validate_identity(agent)
|
|
69
|
+
if not agent.strip():
|
|
70
|
+
raise PolicyBundleError("agent must not be empty")
|
|
71
|
+
if profile not in selector.PROFILES:
|
|
72
|
+
raise PolicyBundleError(
|
|
73
|
+
f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
|
|
74
|
+
)
|
|
75
|
+
if created_at is not None:
|
|
76
|
+
problem = rfc3339_offset_error(created_at)
|
|
77
|
+
if problem:
|
|
78
|
+
raise PolicyBundleError(problem)
|
|
79
|
+
|
|
80
|
+
reader = store if store is not None else RuntimeGraphStore()
|
|
81
|
+
|
|
82
|
+
def fetch() -> dict[str, Any]:
|
|
83
|
+
state = reader.state()
|
|
84
|
+
contracts = sorted(reader.exposed_tool_contracts(), key=lambda row: row["tool_key"])
|
|
85
|
+
for contract in contracts:
|
|
86
|
+
validate_identity(contract["tool_key"])
|
|
87
|
+
# Consumers key decisions by tool_key and may reject the whole bundle
|
|
88
|
+
# on a duplicate (the schema's tools description makes this normative),
|
|
89
|
+
# so failing compilation here is strictly better than shipping one.
|
|
90
|
+
duplicates = sorted(
|
|
91
|
+
key
|
|
92
|
+
for key, count in Counter(row["tool_key"] for row in contracts).items()
|
|
93
|
+
if count > 1
|
|
94
|
+
)
|
|
95
|
+
if duplicates:
|
|
96
|
+
raise PolicyBundleError(
|
|
97
|
+
f"duplicate tool_key rows in exposed catalog: {', '.join(duplicates)}"
|
|
98
|
+
" — repair the graph (e.g. multiple EXPOSES edges per tool) before"
|
|
99
|
+
" compiling"
|
|
100
|
+
)
|
|
101
|
+
candidates = [row["tool_key"] for row in contracts]
|
|
102
|
+
# One graph evaluation per bounded chunk when the adapter supports
|
|
103
|
+
# it: the hard filter is a pure function over the ranked features
|
|
104
|
+
# (ADR-0005), so compiling must not run the full selector pass twice
|
|
105
|
+
# per bracket attempt. Adapters without ``evaluate`` keep the original
|
|
106
|
+
# two-call contract — their rank rows never promised selector-internal
|
|
107
|
+
# fields, so we must not filter them ourselves.
|
|
108
|
+
evaluate = getattr(reader, "evaluate", None)
|
|
109
|
+
filtered = {"eligible": [], "rejected": []}
|
|
110
|
+
features = []
|
|
111
|
+
# The empty catalog still evaluates identity once. Every chunk is
|
|
112
|
+
# inside the same bracket; a state change retries the entire catalog.
|
|
113
|
+
chunks = [
|
|
114
|
+
candidates[i:i + selector.MAX_CANDIDATES]
|
|
115
|
+
for i in range(0, len(candidates), selector.MAX_CANDIDATES)
|
|
116
|
+
] or [[]]
|
|
117
|
+
for chunk in chunks:
|
|
118
|
+
if evaluate is not None:
|
|
119
|
+
evaluated = evaluate(agent, chunk, profile)
|
|
120
|
+
if not evaluated["agent_found"]:
|
|
121
|
+
raise PolicyBundleError(f"agent {agent!r} not found in the graph")
|
|
122
|
+
decisions = evaluated["filtered"]
|
|
123
|
+
rows = evaluated["features"]
|
|
124
|
+
else:
|
|
125
|
+
decisions = reader.eligible_tools(agent, chunk, profile)
|
|
126
|
+
ranked = reader.rank_features(agent, chunk)
|
|
127
|
+
if not decisions["agent_found"] or not ranked["agent_found"]:
|
|
128
|
+
raise PolicyBundleError(f"agent {agent!r} not found in the graph")
|
|
129
|
+
rows = ranked["features"]
|
|
130
|
+
expected = Counter(chunk)
|
|
131
|
+
feature_keys = Counter(row.get("tool_key") or row["candidate"] for row in rows)
|
|
132
|
+
if feature_keys != expected:
|
|
133
|
+
raise PolicyBundleError(
|
|
134
|
+
"selector returned no feature row or duplicate/unexpected "
|
|
135
|
+
"feature rows for catalog"
|
|
136
|
+
)
|
|
137
|
+
decision_keys = Counter(decisions["eligible"])
|
|
138
|
+
decision_keys.update(
|
|
139
|
+
row.get("tool_key") or row["candidate"]
|
|
140
|
+
for row in decisions["rejected"]
|
|
141
|
+
)
|
|
142
|
+
if decision_keys != expected:
|
|
143
|
+
raise PolicyBundleError("selector returned missing, duplicate, or conflicting decisions")
|
|
144
|
+
filtered["eligible"].extend(decisions["eligible"])
|
|
145
|
+
filtered["rejected"].extend(decisions["rejected"])
|
|
146
|
+
features.extend(rows)
|
|
147
|
+
return {
|
|
148
|
+
"governance_digest": state.governance_digest,
|
|
149
|
+
"contracts": contracts,
|
|
150
|
+
"filtered": filtered,
|
|
151
|
+
"features": features,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
compiled = queries.with_graph_state(fetch, strict=True, state_reader=reader.state)
|
|
155
|
+
graph_state = compiled["graph_state"]
|
|
156
|
+
if not isinstance(graph_state.get("instance_id"), str) or not graph_state["instance_id"]:
|
|
157
|
+
raise PolicyBundleError(
|
|
158
|
+
"graph has no instance id — run init-schema before compiling"
|
|
159
|
+
)
|
|
160
|
+
governance_digest = compiled["governance_digest"]
|
|
161
|
+
if not governance_digest:
|
|
162
|
+
raise PolicyBundleError(
|
|
163
|
+
"graph has no governance digest — re-run ingest-manifest before compiling"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
rejected = {
|
|
167
|
+
row.get("tool_key") or row["candidate"]: row
|
|
168
|
+
for row in compiled["filtered"]["rejected"]
|
|
169
|
+
}
|
|
170
|
+
features = {
|
|
171
|
+
row.get("tool_key") or row["candidate"]: row
|
|
172
|
+
for row in compiled["features"]
|
|
173
|
+
}
|
|
174
|
+
tools: list[dict[str, Any]] = []
|
|
175
|
+
for contract in compiled["contracts"]:
|
|
176
|
+
key = contract["tool_key"]
|
|
177
|
+
rejection = rejected.get(key)
|
|
178
|
+
feature = features.get(key)
|
|
179
|
+
if feature is None:
|
|
180
|
+
# Both passes ran inside one graph-state bracket, so every catalog
|
|
181
|
+
# key must have a feature row; a miss means the selector contract
|
|
182
|
+
# broke — fail as a typed compile error, not a KeyError traceback.
|
|
183
|
+
raise PolicyBundleError(
|
|
184
|
+
f"selector returned no feature row for catalog tool {key!r}"
|
|
185
|
+
)
|
|
186
|
+
item: dict[str, Any] = {
|
|
187
|
+
"tool_key": key,
|
|
188
|
+
"tool_contract_digest": tool_contract_digest(contract),
|
|
189
|
+
"decision": "rejected" if rejection else "eligible",
|
|
190
|
+
"risk_score": feature["risk_score"],
|
|
191
|
+
}
|
|
192
|
+
if rejection:
|
|
193
|
+
item["reason"] = rejection["reason"]
|
|
194
|
+
if rejection.get("paths"):
|
|
195
|
+
item["paths"] = rejection["paths"]
|
|
196
|
+
tools.append(item)
|
|
197
|
+
|
|
198
|
+
timestamp = created_at or datetime.now(timezone.utc)
|
|
199
|
+
return safe_artifact(
|
|
200
|
+
{
|
|
201
|
+
"schema_version": SCHEMA_VERSION,
|
|
202
|
+
"kind": KIND,
|
|
203
|
+
"created_at": timestamp.isoformat(),
|
|
204
|
+
"graph_state": compiled["graph_state"],
|
|
205
|
+
"agent": agent,
|
|
206
|
+
"agent_found": True,
|
|
207
|
+
"profile": profile,
|
|
208
|
+
"governance_digest": governance_digest,
|
|
209
|
+
"catalog_digest": _catalog_digest(compiled["contracts"]),
|
|
210
|
+
"tools": tools,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Non-mutating bridge from accepted Tracegraph evidence to policy work.
|
|
2
|
+
|
|
3
|
+
Trace failures are observations, not authorization facts. This compiler makes
|
|
4
|
+
the hand-off explicit: it binds accepted human review dispositions to the
|
|
5
|
+
current graph decision and produces a private work-plan artifact. Applying a
|
|
6
|
+
change still requires editing and ingesting an authored governance manifest.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from toolgraph.artifacts import rfc3339_offset_error
|
|
16
|
+
from toolgraph.artifact_safety import safe_artifact, validate_identity
|
|
17
|
+
from toolgraph.graph import queries, selector
|
|
18
|
+
from toolgraph.review_candidates import ReviewCandidateError, list_candidates
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = 1
|
|
21
|
+
KIND = "toolgraph.policy-review-plan"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PolicyReviewPlanError(RuntimeError):
|
|
25
|
+
"""Accepted evidence cannot be projected onto a trustworthy graph state."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_policy_review_plan(
|
|
29
|
+
*,
|
|
30
|
+
report: Path,
|
|
31
|
+
agent: str,
|
|
32
|
+
profile: str = selector.DEFAULT_PROFILE,
|
|
33
|
+
annotations: Path | None = None,
|
|
34
|
+
created_at: datetime | None = None,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Bind accepted Tracegraph candidates to current Toolgraph decisions."""
|
|
37
|
+
validate_identity(agent)
|
|
38
|
+
if created_at is not None and (problem := rfc3339_offset_error(created_at)):
|
|
39
|
+
raise PolicyReviewPlanError(problem)
|
|
40
|
+
if not agent.strip():
|
|
41
|
+
raise PolicyReviewPlanError("agent must not be empty")
|
|
42
|
+
if profile not in selector.PROFILES:
|
|
43
|
+
raise PolicyReviewPlanError(f"unknown profile {profile!r}")
|
|
44
|
+
try:
|
|
45
|
+
reviewed = list_candidates(
|
|
46
|
+
report, annotations_path=annotations, status="accepted"
|
|
47
|
+
)
|
|
48
|
+
except ReviewCandidateError as exc:
|
|
49
|
+
raise PolicyReviewPlanError(str(exc)) from exc
|
|
50
|
+
accepted = reviewed["candidates"]
|
|
51
|
+
if not accepted:
|
|
52
|
+
raise PolicyReviewPlanError("the review report has no accepted candidates")
|
|
53
|
+
|
|
54
|
+
tool_keys = sorted({row["tool_key"] for row in accepted})
|
|
55
|
+
|
|
56
|
+
def fetch() -> dict[str, Any]:
|
|
57
|
+
verdict = selector.eligible_tools(agent, tool_keys, profile)
|
|
58
|
+
if not verdict["agent_found"]:
|
|
59
|
+
raise PolicyReviewPlanError(f"agent {agent!r} not found in the graph")
|
|
60
|
+
return verdict
|
|
61
|
+
|
|
62
|
+
compiled = queries.with_graph_state(fetch, strict=True)
|
|
63
|
+
rejected = {
|
|
64
|
+
row.get("tool_key") or row["candidate"]: row
|
|
65
|
+
for row in compiled["rejected"]
|
|
66
|
+
}
|
|
67
|
+
candidates = []
|
|
68
|
+
for row in accepted:
|
|
69
|
+
rejection = rejected.get(row["tool_key"])
|
|
70
|
+
candidates.append(
|
|
71
|
+
{
|
|
72
|
+
"candidate_id": row["candidate_id"],
|
|
73
|
+
"run_id": row["run_id"],
|
|
74
|
+
"pattern_id": row["pattern_id"],
|
|
75
|
+
"pattern_version": row["pattern_version"],
|
|
76
|
+
"tool_key": row["tool_key"],
|
|
77
|
+
"artifact_digest": row["artifact_digest"],
|
|
78
|
+
"current_decision": "rejected" if rejection else "eligible",
|
|
79
|
+
"current_reason": rejection.get("reason") if rejection else None,
|
|
80
|
+
"current_paths": rejection.get("paths", []) if rejection else [],
|
|
81
|
+
"recommended_action": "review_governance_manifest",
|
|
82
|
+
"automatic_change": False,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
timestamp = created_at or datetime.now(timezone.utc)
|
|
87
|
+
return safe_artifact({
|
|
88
|
+
"schema_version": SCHEMA_VERSION,
|
|
89
|
+
"kind": KIND,
|
|
90
|
+
"created_at": timestamp.isoformat(),
|
|
91
|
+
"source_report_digest": reviewed["source_report_digest"],
|
|
92
|
+
"graph_state": compiled["graph_state"],
|
|
93
|
+
"agent": agent,
|
|
94
|
+
"profile": profile,
|
|
95
|
+
"decision_mode": "human_required",
|
|
96
|
+
"candidates": candidates,
|
|
97
|
+
})
|
toolgraph/preflight.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Deterministic advisory preflight artifact production."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from toolgraph.artifacts import rfc3339_offset_error
|
|
7
|
+
from toolgraph.artifact_safety import safe_artifact, validate_identity
|
|
8
|
+
from toolgraph.artifact_safety import redact as redact
|
|
9
|
+
|
|
10
|
+
from toolgraph.graph import queries, selector
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
KIND = "toolgraph.preflight"
|
|
14
|
+
|
|
15
|
+
def build_preflight(
|
|
16
|
+
*,
|
|
17
|
+
agent: str,
|
|
18
|
+
candidates: list[str],
|
|
19
|
+
profile: str,
|
|
20
|
+
run_id: str,
|
|
21
|
+
include_features: bool = False,
|
|
22
|
+
created_at: datetime | None = None,
|
|
23
|
+
) -> dict:
|
|
24
|
+
"""Build one schema-v1 advisory verdict over a bracketed graph state."""
|
|
25
|
+
for identity in [agent, run_id, *candidates]:
|
|
26
|
+
validate_identity(identity)
|
|
27
|
+
if created_at is not None and (problem := rfc3339_offset_error(created_at)):
|
|
28
|
+
raise ValueError(problem)
|
|
29
|
+
if not run_id:
|
|
30
|
+
raise ValueError("run_id must not be empty")
|
|
31
|
+
if profile not in selector.PROFILES:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def fetch() -> dict:
|
|
37
|
+
# One graph evaluation: the filter is a pure function over the ranked
|
|
38
|
+
# features, so --features must not pay a second full pass.
|
|
39
|
+
ranked = selector.rank_features(agent, candidates)
|
|
40
|
+
filtered = selector.filter_features(ranked, profile)
|
|
41
|
+
result = {
|
|
42
|
+
"agent": agent,
|
|
43
|
+
"agent_found": filtered["agent_found"],
|
|
44
|
+
"profile": profile,
|
|
45
|
+
"eligible": filtered["eligible"],
|
|
46
|
+
"rejected": filtered["rejected"],
|
|
47
|
+
}
|
|
48
|
+
if include_features:
|
|
49
|
+
result["features"] = ranked["features"]
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
verdict = queries.with_graph_state(fetch, strict=True)
|
|
53
|
+
if not verdict["agent_found"]:
|
|
54
|
+
decision = "unresolved_identity"
|
|
55
|
+
elif verdict["rejected"]:
|
|
56
|
+
decision = "advisory_warn"
|
|
57
|
+
else:
|
|
58
|
+
decision = "advisory_allow"
|
|
59
|
+
timestamp = created_at or datetime.now(timezone.utc)
|
|
60
|
+
artifact = {
|
|
61
|
+
"schema_version": SCHEMA_VERSION,
|
|
62
|
+
"kind": KIND,
|
|
63
|
+
"run_id": run_id,
|
|
64
|
+
"created_at": timestamp.isoformat(),
|
|
65
|
+
"graph_generation": verdict["graph_generation"],
|
|
66
|
+
"graph_instance_id": verdict["graph_instance_id"],
|
|
67
|
+
"graph_state": verdict["graph_state"],
|
|
68
|
+
"agent": agent,
|
|
69
|
+
"agent_found": verdict["agent_found"],
|
|
70
|
+
"profile": profile,
|
|
71
|
+
"mode": "advisory", # v1 emits advisory artifacts only
|
|
72
|
+
"eligible": verdict["eligible"],
|
|
73
|
+
"rejected": verdict["rejected"],
|
|
74
|
+
"features": verdict.get("features", []),
|
|
75
|
+
}
|
|
76
|
+
artifact["decision"] = decision
|
|
77
|
+
return safe_artifact(artifact)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
agents:
|
|
2
|
+
- vibe-coder
|
|
3
|
+
|
|
4
|
+
policies:
|
|
5
|
+
- id: draft-publish-deny
|
|
6
|
+
effect: DENY
|
|
7
|
+
scope: demo-drafts
|
|
8
|
+
description: Publishing demo drafts requires an explicit policy review.
|
|
9
|
+
|
|
10
|
+
grants:
|
|
11
|
+
- agent: vibe-coder
|
|
12
|
+
tool: "policy-gateway::read_note"
|
|
13
|
+
granted_by: quickstart
|
|
14
|
+
- agent: vibe-coder
|
|
15
|
+
tool: "policy-gateway::publish_note"
|
|
16
|
+
granted_by: quickstart
|
|
17
|
+
|
|
18
|
+
data_access:
|
|
19
|
+
- tool: "policy-gateway::read_note"
|
|
20
|
+
resource: "file:///demo/notes"
|
|
21
|
+
mode: READS
|
|
22
|
+
- tool: "policy-gateway::publish_note"
|
|
23
|
+
resource: "file:///demo/drafts"
|
|
24
|
+
mode: WRITES
|
|
25
|
+
|
|
26
|
+
governed_by:
|
|
27
|
+
"file:///demo/drafts":
|
|
28
|
+
- draft-publish-deny
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Deterministic, local-only MCP server for the Toolgraph quickstart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
from mcp.types import ToolAnnotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build() -> FastMCP:
|
|
10
|
+
server = FastMCP("policy-gateway")
|
|
11
|
+
|
|
12
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True))
|
|
13
|
+
def read_note(note_id: str) -> str:
|
|
14
|
+
"""Read a deterministic demo note."""
|
|
15
|
+
return f"note:{note_id}"
|
|
16
|
+
|
|
17
|
+
@server.tool(annotations=ToolAnnotations(destructiveHint=True))
|
|
18
|
+
def publish_note(note_id: str, content: str) -> str:
|
|
19
|
+
"""Publish a demo note to the governed draft area."""
|
|
20
|
+
return f"published:{note_id}:{len(content)}"
|
|
21
|
+
|
|
22
|
+
@server.resource("file:///demo/notes")
|
|
23
|
+
def notes() -> str:
|
|
24
|
+
"""Describe the deterministic demo note collection."""
|
|
25
|
+
return "policy-gateway quickstart notes"
|
|
26
|
+
|
|
27
|
+
return server
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
build().run()
|