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/config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Runtime settings, sourced from the environment (and an optional .env file)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
load_dotenv(dotenv_path=Path.cwd() / ".env", override=False)
|
|
13
|
+
|
|
14
|
+
_explicit_config_path: Path | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def runtime_config_path() -> Path:
|
|
18
|
+
value = _explicit_config_path or os.getenv("TOOLGRAPH_CONFIG", ".toolgraph/config.json")
|
|
19
|
+
return Path(value).expanduser()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RuntimeConfigurationError(RuntimeError):
|
|
23
|
+
"""The selected runtime configuration cannot be loaded."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_MAX_CONFIG_BYTES = 1_000_000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _runtime_config() -> dict:
|
|
30
|
+
path = runtime_config_path()
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return {}
|
|
33
|
+
try:
|
|
34
|
+
# Bounded read from one open descriptor (stat-then-read is a TOCTOU).
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
raw = handle.read(_MAX_CONFIG_BYTES + 1)
|
|
37
|
+
if len(raw) > _MAX_CONFIG_BYTES:
|
|
38
|
+
raise RuntimeConfigurationError(
|
|
39
|
+
f"invalid Toolgraph runtime config at {path}: exceeds"
|
|
40
|
+
f" the {_MAX_CONFIG_BYTES}-byte limit"
|
|
41
|
+
)
|
|
42
|
+
value = json.loads(raw.decode("utf-8"))
|
|
43
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
44
|
+
raise RuntimeConfigurationError(f"invalid Toolgraph runtime config at {path}: {exc}") from exc
|
|
45
|
+
if not isinstance(value, dict) or value.get("schema_version") != 1:
|
|
46
|
+
raise RuntimeConfigurationError(f"unsupported Toolgraph runtime config at {path}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _backend_default() -> str:
|
|
51
|
+
runtime = _runtime_config()
|
|
52
|
+
if _explicit_config_path is not None:
|
|
53
|
+
return str(runtime.get("backend", "neo4j"))
|
|
54
|
+
return os.getenv("TOOLGRAPH_BACKEND") or str(runtime.get("backend", "neo4j"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _db_path_default() -> Path:
|
|
58
|
+
runtime = _runtime_config()
|
|
59
|
+
if _explicit_config_path is not None:
|
|
60
|
+
value = runtime.get("db_path", ".toolgraph/toolgraph.lbug")
|
|
61
|
+
else:
|
|
62
|
+
value = os.getenv("TOOLGRAPH_DB_PATH") or runtime.get(
|
|
63
|
+
"db_path", ".toolgraph/toolgraph.lbug"
|
|
64
|
+
)
|
|
65
|
+
return Path(str(value)).expanduser()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Settings:
|
|
70
|
+
# default_factory so each Settings() re-reads the environment (tests build
|
|
71
|
+
# their own Settings; the module-level singleton reads it once on first access).
|
|
72
|
+
neo4j_uri: str = field(default_factory=lambda: os.getenv("NEO4J_URI", "bolt://localhost:7687"))
|
|
73
|
+
neo4j_user: str = field(default_factory=lambda: os.getenv("NEO4J_USER", "neo4j"))
|
|
74
|
+
neo4j_password: str = field(default_factory=lambda: os.getenv("NEO4J_PASSWORD", "toolgraph-dev"))
|
|
75
|
+
neo4j_database: str = field(default_factory=lambda: os.getenv("NEO4J_DATABASE", "neo4j"))
|
|
76
|
+
backend: str = field(default_factory=_backend_default)
|
|
77
|
+
db_path: Path = field(default_factory=_db_path_default)
|
|
78
|
+
servers_config: Path = field(
|
|
79
|
+
default_factory=lambda: Path(os.getenv("TOOLGRAPH_SERVERS", "servers.yaml"))
|
|
80
|
+
)
|
|
81
|
+
governance_config: Path = field(
|
|
82
|
+
default_factory=lambda: Path(os.getenv("TOOLGRAPH_GOVERNANCE", "governance.yaml"))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def __getattr__(name: str):
|
|
87
|
+
if name == "settings":
|
|
88
|
+
value = Settings()
|
|
89
|
+
globals()[name] = value
|
|
90
|
+
return value
|
|
91
|
+
raise AttributeError(name)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def configure(config_path: Path) -> None:
|
|
95
|
+
"""Select an explicit runtime config (CLI > env > cwd .env > default)."""
|
|
96
|
+
global _explicit_config_path, settings
|
|
97
|
+
selected = config_path.expanduser().resolve()
|
|
98
|
+
if not selected.is_file():
|
|
99
|
+
raise RuntimeConfigurationError(f"Toolgraph runtime config does not exist: {selected}")
|
|
100
|
+
previous = _explicit_config_path
|
|
101
|
+
_explicit_config_path = selected
|
|
102
|
+
try:
|
|
103
|
+
settings = Settings()
|
|
104
|
+
except Exception:
|
|
105
|
+
_explicit_config_path = previous
|
|
106
|
+
raise
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Body-free orchestration control-plan validation and advisory preflight.
|
|
2
|
+
|
|
3
|
+
The plan is an upper-bound control graph supplied by an external orchestrator.
|
|
4
|
+
Toolgraph never executes or persists it: it validates the bounded DAG and joins
|
|
5
|
+
every declared principal/tool surface to the existing governance graph under one
|
|
6
|
+
collision-safe graph-state bracket.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import deque
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
|
|
19
|
+
from toolgraph.artifacts import rfc3339_offset_error, sha256_bytes
|
|
20
|
+
from toolgraph.graph import queries, selector
|
|
21
|
+
from toolgraph.artifact_safety import _CREDENTIAL_QUERY, _CREDENTIAL_URI, safe_artifact
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
PLAN_KIND = "toolgraph.control-plan"
|
|
25
|
+
RESULT_KIND = "toolgraph.control-preflight"
|
|
26
|
+
MAX_PLAN_BYTES = 1_000_000
|
|
27
|
+
# JSON nesting bound for the body-free walk (and json.loads' own recursion).
|
|
28
|
+
_MAX_PLAN_DEPTH = 100
|
|
29
|
+
|
|
30
|
+
_QUALIFIED_TOOL = re.compile(r"^[^:\s]+::[^:\s]+$")
|
|
31
|
+
_FORBIDDEN_KEYS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"api_key",
|
|
34
|
+
"body",
|
|
35
|
+
"command",
|
|
36
|
+
"commands",
|
|
37
|
+
"credential",
|
|
38
|
+
"credentials",
|
|
39
|
+
"env",
|
|
40
|
+
"gate_commands",
|
|
41
|
+
"headers",
|
|
42
|
+
"memory",
|
|
43
|
+
"model_selector",
|
|
44
|
+
"output",
|
|
45
|
+
"outputs",
|
|
46
|
+
"password",
|
|
47
|
+
"patch",
|
|
48
|
+
"prompt",
|
|
49
|
+
"prompts",
|
|
50
|
+
"repo_root",
|
|
51
|
+
"repository_path",
|
|
52
|
+
"secret",
|
|
53
|
+
"stderr",
|
|
54
|
+
"stdout",
|
|
55
|
+
"token",
|
|
56
|
+
"worktree",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
_FORBIDDEN_KEY_FRAGMENTS = (
|
|
60
|
+
"api_key",
|
|
61
|
+
"command",
|
|
62
|
+
"credential",
|
|
63
|
+
"memory_body",
|
|
64
|
+
"model_selector",
|
|
65
|
+
"output",
|
|
66
|
+
"password",
|
|
67
|
+
"patch",
|
|
68
|
+
"prompt",
|
|
69
|
+
"secret",
|
|
70
|
+
"stderr",
|
|
71
|
+
"stdout",
|
|
72
|
+
"token",
|
|
73
|
+
)
|
|
74
|
+
_ABSOLUTE_PATH = re.compile(r"^(?:/|\\\\|[A-Za-z]:[\\/])")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ControlPlanError(ValueError):
|
|
78
|
+
"""The supplied plan cannot produce a trustworthy preflight artifact."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _AdditiveModel(BaseModel):
|
|
82
|
+
"""Versioned artifact model: same-major additive fields are ignored."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="ignore", strict=True)
|
|
85
|
+
|
|
86
|
+
@model_validator(mode="before")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _json_numbers(cls, value):
|
|
89
|
+
if not isinstance(value, dict):
|
|
90
|
+
return value
|
|
91
|
+
value = dict(value)
|
|
92
|
+
for name, field in cls.model_fields.items():
|
|
93
|
+
if name not in value:
|
|
94
|
+
continue
|
|
95
|
+
item = value[name]
|
|
96
|
+
if item is None and not field.is_required():
|
|
97
|
+
raise ValueError(f"{name} cannot be null")
|
|
98
|
+
# JSON Schema treats integral JSON numbers as integers, but never
|
|
99
|
+
# booleans. Pydantic Literal[0/1] alone accepts bool equality.
|
|
100
|
+
numeric = name in {
|
|
101
|
+
"schema_version", "maximum_cycles", "maximum_agent_calls",
|
|
102
|
+
"maximum_parallelism", "max_invocations", "max_parallelism",
|
|
103
|
+
"timeout_seconds", "max_fan_out",
|
|
104
|
+
}
|
|
105
|
+
if numeric:
|
|
106
|
+
if isinstance(item, bool):
|
|
107
|
+
raise ValueError(f"{name} must be an integer")
|
|
108
|
+
if isinstance(item, float) and item.is_integer():
|
|
109
|
+
value[name] = int(item)
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Producer(_AdditiveModel):
|
|
114
|
+
name: str = Field(min_length=1, max_length=128, pattern=r"^\S+$")
|
|
115
|
+
version: str = Field(min_length=1, max_length=128, pattern=r"^\S+$")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Principal(_AdditiveModel):
|
|
119
|
+
id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
|
|
120
|
+
candidate_tools: list[str] = Field(min_length=1, max_length=4096)
|
|
121
|
+
|
|
122
|
+
@model_validator(mode="after")
|
|
123
|
+
def _qualified_unique_tools(self) -> "Principal":
|
|
124
|
+
bad = [tool for tool in self.candidate_tools if not _QUALIFIED_TOOL.fullmatch(tool)]
|
|
125
|
+
if bad:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"candidate_tools must use qualified 'server::tool' keys: "
|
|
128
|
+
+ ", ".join(repr(tool) for tool in bad)
|
|
129
|
+
)
|
|
130
|
+
if len(set(self.candidate_tools)) != len(self.candidate_tools):
|
|
131
|
+
raise ValueError("candidate_tools must be unique within one principal")
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
NodeKind = Literal["start", "agent", "router", "fanout", "join", "validator", "end"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ControlNode(_AdditiveModel):
|
|
139
|
+
id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
|
|
140
|
+
kind: NodeKind
|
|
141
|
+
# Required, not defaulted: contracts/control-plan.schema.json lists both in
|
|
142
|
+
# node.required, and the tolerant side here is the one that stamps the
|
|
143
|
+
# artifact — a plan this model accepted but the vendored schema rejects
|
|
144
|
+
# would make producer and consumer disagree about what a valid v1 plan is.
|
|
145
|
+
principals: list[str] = Field(max_length=256)
|
|
146
|
+
agent_call: bool
|
|
147
|
+
max_invocations: int | None = Field(default=None, ge=1)
|
|
148
|
+
max_parallelism: int | None = Field(default=None, ge=1)
|
|
149
|
+
timeout_seconds: int | None = Field(default=None, ge=1)
|
|
150
|
+
validator_scope: Literal["workspace_result"] | None = None
|
|
151
|
+
|
|
152
|
+
@model_validator(mode="after")
|
|
153
|
+
def _coherent_execution_shape(self) -> "ControlNode":
|
|
154
|
+
if (self.kind == "agent") != self.agent_call:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"node {self.id!r} kind='agent' and agent_call must agree"
|
|
157
|
+
)
|
|
158
|
+
if len(set(self.principals)) != len(self.principals):
|
|
159
|
+
raise ValueError(f"node {self.id!r} principals must be unique")
|
|
160
|
+
if self.agent_call:
|
|
161
|
+
if not self.principals:
|
|
162
|
+
raise ValueError(f"agent-call node {self.id!r} requires principals")
|
|
163
|
+
missing = [
|
|
164
|
+
name
|
|
165
|
+
for name, value in (
|
|
166
|
+
("max_invocations", self.max_invocations),
|
|
167
|
+
("max_parallelism", self.max_parallelism),
|
|
168
|
+
("timeout_seconds", self.timeout_seconds),
|
|
169
|
+
)
|
|
170
|
+
if value is None
|
|
171
|
+
]
|
|
172
|
+
if missing:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"agent-call node {self.id!r} requires {', '.join(missing)}"
|
|
175
|
+
)
|
|
176
|
+
assert self.max_invocations is not None
|
|
177
|
+
assert self.max_parallelism is not None
|
|
178
|
+
if self.max_parallelism > self.max_invocations:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"node {self.id!r} max_parallelism cannot exceed max_invocations"
|
|
181
|
+
)
|
|
182
|
+
elif any(
|
|
183
|
+
value is not None
|
|
184
|
+
for value in (self.max_invocations, self.max_parallelism, self.timeout_seconds)
|
|
185
|
+
):
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"non-agent-call node {self.id!r} cannot declare execution bounds"
|
|
188
|
+
)
|
|
189
|
+
if self.kind == "validator" and self.validator_scope is None:
|
|
190
|
+
raise ValueError(f"validator node {self.id!r} requires validator_scope")
|
|
191
|
+
if self.kind != "validator" and self.validator_scope is not None:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"non-validator node {self.id!r} cannot declare validator_scope"
|
|
194
|
+
)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
EdgeKind = Literal["always", "success", "failure", "fallback", "fanout", "join"]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ControlEdge(_AdditiveModel):
|
|
202
|
+
source: str = Field(alias="from", min_length=1, max_length=256, pattern=r"^\S+$")
|
|
203
|
+
target: str = Field(alias="to", min_length=1, max_length=256, pattern=r"^\S+$")
|
|
204
|
+
kind: EdgeKind
|
|
205
|
+
decided_by: Literal["code", "model", "human"]
|
|
206
|
+
max_fan_out: int | None = Field(default=None, ge=1)
|
|
207
|
+
|
|
208
|
+
@model_validator(mode="after")
|
|
209
|
+
def _coherent_fanout_bound(self) -> "ControlEdge":
|
|
210
|
+
if self.kind != "fanout" and self.max_fan_out is not None:
|
|
211
|
+
raise ValueError("max_fan_out is valid only on fanout edges")
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ControlLimits(_AdditiveModel):
|
|
216
|
+
maximum_agent_calls: int = Field(ge=1)
|
|
217
|
+
maximum_parallelism: int = Field(ge=1)
|
|
218
|
+
maximum_cycles: Literal[0]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ControlPlan(_AdditiveModel):
|
|
222
|
+
schema_version: Literal[1]
|
|
223
|
+
kind: Literal["toolgraph.control-plan"]
|
|
224
|
+
run_id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
|
|
225
|
+
producer: Producer
|
|
226
|
+
principals: list[Principal] = Field(min_length=1, max_length=256)
|
|
227
|
+
nodes: list[ControlNode] = Field(min_length=1, max_length=4096)
|
|
228
|
+
edges: list[ControlEdge] = Field(max_length=16384)
|
|
229
|
+
limits: ControlLimits
|
|
230
|
+
|
|
231
|
+
@model_validator(mode="after")
|
|
232
|
+
def _unique_identities(self) -> "ControlPlan":
|
|
233
|
+
principal_ids = [principal.id for principal in self.principals]
|
|
234
|
+
if len(set(principal_ids)) != len(principal_ids):
|
|
235
|
+
raise ValueError("principal ids must be unique")
|
|
236
|
+
node_ids = [node.id for node in self.nodes]
|
|
237
|
+
if len(set(node_ids)) != len(node_ids):
|
|
238
|
+
raise ValueError("node ids must be unique")
|
|
239
|
+
known = set(principal_ids)
|
|
240
|
+
missing = sorted(
|
|
241
|
+
{
|
|
242
|
+
principal
|
|
243
|
+
for node in self.nodes
|
|
244
|
+
for principal in node.principals
|
|
245
|
+
if principal not in known
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
if missing:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
"nodes reference undeclared principals: " + ", ".join(missing)
|
|
251
|
+
)
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _assert_body_free(value: object, *, path: tuple[str, ...] = ()) -> None:
|
|
256
|
+
# A 1MB plan can still nest thousands of levels deep; cap the walk so a
|
|
257
|
+
# pathological plan fails as a typed error instead of a RecursionError.
|
|
258
|
+
if len(path) > _MAX_PLAN_DEPTH:
|
|
259
|
+
raise ControlPlanError(
|
|
260
|
+
f"control plan nesting exceeds the {_MAX_PLAN_DEPTH}-level limit"
|
|
261
|
+
)
|
|
262
|
+
if isinstance(value, dict):
|
|
263
|
+
for key, child in value.items():
|
|
264
|
+
lowered = str(key).lower()
|
|
265
|
+
if lowered in _FORBIDDEN_KEYS or any(
|
|
266
|
+
fragment in lowered for fragment in _FORBIDDEN_KEY_FRAGMENTS
|
|
267
|
+
):
|
|
268
|
+
where = ".".join((*path, str(key)))
|
|
269
|
+
raise ControlPlanError(f"control plan contains forbidden field {where!r}")
|
|
270
|
+
_assert_body_free(child, path=(*path, str(key)))
|
|
271
|
+
return
|
|
272
|
+
if isinstance(value, list):
|
|
273
|
+
for index, child in enumerate(value):
|
|
274
|
+
_assert_body_free(child, path=(*path, str(index)))
|
|
275
|
+
return
|
|
276
|
+
if isinstance(value, str):
|
|
277
|
+
if _CREDENTIAL_URI.search(value):
|
|
278
|
+
raise ControlPlanError("control plan contains a credential-bearing URI")
|
|
279
|
+
if _CREDENTIAL_QUERY.search(value):
|
|
280
|
+
raise ControlPlanError("control plan contains a credential-bearing query")
|
|
281
|
+
if _ABSOLUTE_PATH.match(value):
|
|
282
|
+
raise ControlPlanError("control plan contains an absolute filesystem path")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> dict:
|
|
286
|
+
value: dict = {}
|
|
287
|
+
for key, child in pairs:
|
|
288
|
+
if key in value:
|
|
289
|
+
raise ControlPlanError(f"control plan contains duplicate key {key!r}")
|
|
290
|
+
value[key] = child
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def load_control_plan(raw: bytes) -> ControlPlan:
|
|
295
|
+
"""Parse one size-bounded, body-free, same-major additive plan."""
|
|
296
|
+
if len(raw) > MAX_PLAN_BYTES:
|
|
297
|
+
raise ControlPlanError(
|
|
298
|
+
f"control plan exceeds the {MAX_PLAN_BYTES}-byte input limit"
|
|
299
|
+
)
|
|
300
|
+
try:
|
|
301
|
+
decoded = json.loads(raw, object_pairs_hook=_unique_object)
|
|
302
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
303
|
+
raise ControlPlanError(f"control plan is not valid UTF-8 JSON: {exc}") from exc
|
|
304
|
+
except RecursionError as exc:
|
|
305
|
+
# json.loads itself recurses; a deeply nested plan must fail typed.
|
|
306
|
+
raise ControlPlanError(
|
|
307
|
+
f"control plan nesting exceeds the {_MAX_PLAN_DEPTH}-level limit"
|
|
308
|
+
) from exc
|
|
309
|
+
if not isinstance(decoded, dict):
|
|
310
|
+
raise ControlPlanError("control plan must be a JSON object")
|
|
311
|
+
_assert_body_free(decoded)
|
|
312
|
+
try:
|
|
313
|
+
return ControlPlan.model_validate(decoded)
|
|
314
|
+
except ValueError as exc:
|
|
315
|
+
raise ControlPlanError(f"invalid control plan: {exc}") from exc
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _finding(code: str, *, nodes: list[str] | None = None, edges: list[int] | None = None) -> dict:
|
|
319
|
+
row: dict = {"code": code}
|
|
320
|
+
if nodes:
|
|
321
|
+
row["nodes"] = sorted(set(nodes))
|
|
322
|
+
if edges:
|
|
323
|
+
row["edges"] = sorted(set(edges))
|
|
324
|
+
return row
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def lint_control_plan(plan: ControlPlan) -> list[dict]:
|
|
328
|
+
"""Return stable structural findings for the bounded v1 DAG contract."""
|
|
329
|
+
findings: list[dict] = []
|
|
330
|
+
nodes = {node.id: node for node in plan.nodes}
|
|
331
|
+
starts = sorted(node.id for node in plan.nodes if node.kind == "start")
|
|
332
|
+
ends = sorted(node.id for node in plan.nodes if node.kind == "end")
|
|
333
|
+
if len(starts) != 1:
|
|
334
|
+
findings.append(_finding("START_NODE_COUNT", nodes=starts))
|
|
335
|
+
if len(ends) != 1:
|
|
336
|
+
findings.append(_finding("END_NODE_COUNT", nodes=ends))
|
|
337
|
+
|
|
338
|
+
unknown_edges: list[int] = []
|
|
339
|
+
adjacency: dict[str, list[str]] = {node_id: [] for node_id in nodes}
|
|
340
|
+
reverse: dict[str, list[str]] = {node_id: [] for node_id in nodes}
|
|
341
|
+
indegree = {node_id: 0 for node_id in nodes}
|
|
342
|
+
for index, edge in enumerate(plan.edges):
|
|
343
|
+
if edge.source not in nodes or edge.target not in nodes:
|
|
344
|
+
unknown_edges.append(index)
|
|
345
|
+
continue
|
|
346
|
+
adjacency[edge.source].append(edge.target)
|
|
347
|
+
reverse[edge.target].append(edge.source)
|
|
348
|
+
indegree[edge.target] += 1
|
|
349
|
+
if unknown_edges:
|
|
350
|
+
findings.append(_finding("UNKNOWN_NODE_REFERENCE", edges=unknown_edges))
|
|
351
|
+
|
|
352
|
+
if len(starts) == 1:
|
|
353
|
+
reachable: set[str] = set()
|
|
354
|
+
queue = deque(starts)
|
|
355
|
+
while queue:
|
|
356
|
+
current = queue.popleft()
|
|
357
|
+
if current in reachable:
|
|
358
|
+
continue
|
|
359
|
+
reachable.add(current)
|
|
360
|
+
queue.extend(adjacency[current])
|
|
361
|
+
missing = sorted(set(nodes) - reachable)
|
|
362
|
+
if missing:
|
|
363
|
+
findings.append(_finding("UNREACHABLE_NODE", nodes=missing))
|
|
364
|
+
|
|
365
|
+
if len(ends) == 1:
|
|
366
|
+
terminal: set[str] = set()
|
|
367
|
+
queue = deque(ends)
|
|
368
|
+
while queue:
|
|
369
|
+
current = queue.popleft()
|
|
370
|
+
if current in terminal:
|
|
371
|
+
continue
|
|
372
|
+
terminal.add(current)
|
|
373
|
+
queue.extend(reverse[current])
|
|
374
|
+
missing = sorted(set(nodes) - terminal)
|
|
375
|
+
if missing:
|
|
376
|
+
findings.append(_finding("NO_TERMINAL_PATH", nodes=missing))
|
|
377
|
+
|
|
378
|
+
remaining = dict(indegree)
|
|
379
|
+
queue = deque(sorted(node_id for node_id, degree in remaining.items() if degree == 0))
|
|
380
|
+
visited = 0
|
|
381
|
+
while queue:
|
|
382
|
+
current = queue.popleft()
|
|
383
|
+
visited += 1
|
|
384
|
+
for target in adjacency[current]:
|
|
385
|
+
remaining[target] -= 1
|
|
386
|
+
if remaining[target] == 0:
|
|
387
|
+
queue.append(target)
|
|
388
|
+
if visited != len(nodes):
|
|
389
|
+
findings.append(
|
|
390
|
+
_finding(
|
|
391
|
+
"CONTROL_CYCLE_UNSUPPORTED",
|
|
392
|
+
nodes=[node_id for node_id, degree in remaining.items() if degree > 0],
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
unbounded_model_fanout = [
|
|
397
|
+
index
|
|
398
|
+
for index, edge in enumerate(plan.edges)
|
|
399
|
+
if edge.kind == "fanout" and edge.decided_by == "model" and edge.max_fan_out is None
|
|
400
|
+
]
|
|
401
|
+
if unbounded_model_fanout:
|
|
402
|
+
findings.append(
|
|
403
|
+
_finding("MODEL_FANOUT_UNBOUNDED", edges=unbounded_model_fanout)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
planned_calls = sum(
|
|
407
|
+
node.max_invocations or 0 for node in plan.nodes if node.agent_call
|
|
408
|
+
)
|
|
409
|
+
if planned_calls > plan.limits.maximum_agent_calls:
|
|
410
|
+
findings.append(
|
|
411
|
+
{
|
|
412
|
+
"code": "CALL_BOUND_TOO_LOW",
|
|
413
|
+
"declared": plan.limits.maximum_agent_calls,
|
|
414
|
+
"required": planned_calls,
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
planned_parallelism = max(
|
|
418
|
+
(node.max_parallelism or 0 for node in plan.nodes if node.agent_call),
|
|
419
|
+
default=0,
|
|
420
|
+
)
|
|
421
|
+
if planned_parallelism > plan.limits.maximum_parallelism:
|
|
422
|
+
findings.append(
|
|
423
|
+
{
|
|
424
|
+
"code": "PARALLEL_BOUND_TOO_LOW",
|
|
425
|
+
"declared": plan.limits.maximum_parallelism,
|
|
426
|
+
"required": planned_parallelism,
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
return findings
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def build_control_preflight(
|
|
433
|
+
raw_plan: bytes,
|
|
434
|
+
*,
|
|
435
|
+
profile: str = selector.DEFAULT_PROFILE,
|
|
436
|
+
created_at: datetime | None = None,
|
|
437
|
+
) -> dict:
|
|
438
|
+
"""Evaluate a valid control plan as one advisory, graph-state-bound artifact."""
|
|
439
|
+
if profile not in selector.PROFILES:
|
|
440
|
+
raise ControlPlanError(
|
|
441
|
+
f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
|
|
442
|
+
)
|
|
443
|
+
if created_at is not None:
|
|
444
|
+
problem = rfc3339_offset_error(created_at)
|
|
445
|
+
if problem:
|
|
446
|
+
raise ControlPlanError(problem)
|
|
447
|
+
plan = load_control_plan(raw_plan)
|
|
448
|
+
findings = lint_control_plan(plan)
|
|
449
|
+
|
|
450
|
+
def fetch() -> dict:
|
|
451
|
+
evaluations = []
|
|
452
|
+
for principal in plan.principals:
|
|
453
|
+
verdict = selector.eligible_tools(
|
|
454
|
+
principal.id, principal.candidate_tools, profile=profile
|
|
455
|
+
)
|
|
456
|
+
evaluations.append(
|
|
457
|
+
{
|
|
458
|
+
"principal": principal.id,
|
|
459
|
+
"agent_found": verdict["agent_found"],
|
|
460
|
+
"eligible": verdict["eligible"],
|
|
461
|
+
"rejected": verdict["rejected"],
|
|
462
|
+
}
|
|
463
|
+
)
|
|
464
|
+
return {"evaluations": evaluations}
|
|
465
|
+
|
|
466
|
+
compiled = queries.with_graph_state(fetch, strict=True)
|
|
467
|
+
graph_state = compiled["graph_state"]
|
|
468
|
+
if not isinstance(graph_state.get("instance_id"), str) or not graph_state["instance_id"]:
|
|
469
|
+
raise ControlPlanError("graph has no instance id — run init-schema before preflight")
|
|
470
|
+
|
|
471
|
+
warned = bool(findings) or any(
|
|
472
|
+
not evaluation["agent_found"] or evaluation["rejected"]
|
|
473
|
+
for evaluation in compiled["evaluations"]
|
|
474
|
+
)
|
|
475
|
+
timestamp = created_at or datetime.now(timezone.utc)
|
|
476
|
+
return {
|
|
477
|
+
"schema_version": SCHEMA_VERSION,
|
|
478
|
+
"kind": RESULT_KIND,
|
|
479
|
+
"run_id": plan.run_id,
|
|
480
|
+
"created_at": timestamp.isoformat(),
|
|
481
|
+
"mode": "advisory",
|
|
482
|
+
"profile": profile,
|
|
483
|
+
"plan_digest": f"sha256:{sha256_bytes(raw_plan)}",
|
|
484
|
+
"graph_state": graph_state,
|
|
485
|
+
"decision": "advisory_warn" if warned else "advisory_allow",
|
|
486
|
+
# Redaction covers graph evidence only. Applied to the whole artifact
|
|
487
|
+
# it also rewrites identities: a URI-shaped run_id loses its query, and
|
|
488
|
+
# principals differing only there collapse into one output identity —
|
|
489
|
+
# which breaks the run correlation this artifact exists for. Identity
|
|
490
|
+
# fields are already credential-free; load_control_plan rejects a plan
|
|
491
|
+
# carrying credential-shaped values.
|
|
492
|
+
"evaluations": safe_artifact(compiled["evaluations"]),
|
|
493
|
+
"findings": findings,
|
|
494
|
+
}
|
|
File without changes
|