syncmill 0.2.0__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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/preflight.py
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
"""Toolgraph advisory preflight (S5) and strict enforcement (P3.1).
|
|
2
|
+
|
|
3
|
+
Before dispatching a strategy, the Supervisor optionally runs
|
|
4
|
+
``toolgraph preflight`` once — for the lead matched agent's mapped toolgraph
|
|
5
|
+
identity — to check the run's ambient MCP surface (memtomem, syncmill's own
|
|
6
|
+
read-only ``serve`` gateway) against toolgraph's policy graph. It is
|
|
7
|
+
**advisory**: every failure class (binary absent, timeout, non-zero exit, bad
|
|
8
|
+
JSON, unknown major version) degrades to a yellow warning and the run
|
|
9
|
+
CONTINUES — preflight never blocks or aborts orchestration (the memory idiom).
|
|
10
|
+
The outcome lands in the run envelope's ``preflight`` block; when reporting is
|
|
11
|
+
enabled the artifact is copied to ``{run_dir}/preflight.json`` and digested.
|
|
12
|
+
|
|
13
|
+
syncmill agents are CLI subprocesses that do not themselves call MCP tools, so
|
|
14
|
+
this checks the *ecosystem's* knowledge surface for the mapped identity — it
|
|
15
|
+
does not claim per-attempt tool mediation (the design's honest tier: identity
|
|
16
|
+
validation + generation stamp always; ambient-surface policy check when
|
|
17
|
+
``candidate_tools`` is configured; per-attempt mediation deliberately reserved).
|
|
18
|
+
|
|
19
|
+
Strict mode keeps Toolgraph as an advisory producer but validates every selected
|
|
20
|
+
identity, applies SyncMill's category policies, and writes a body-free aggregate
|
|
21
|
+
before the Supervisor decides whether to dispatch. Disabled by default
|
|
22
|
+
(``build``-idiom ``None``): no subprocess, byte-identical behavior.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
import tempfile
|
|
32
|
+
from collections import Counter
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from syncmill.agents import _sync_run
|
|
37
|
+
from syncmill.config import ToolgraphConfig
|
|
38
|
+
# `planning` does not import `preflight`, so this stays acyclic. The sentinel
|
|
39
|
+
# spelling has exactly one producer, and this is the only consumer that has to
|
|
40
|
+
# recognise it.
|
|
41
|
+
from syncmill.planning import unmapped_principal_id
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
PREFLIGHT_FILE = "preflight.json"
|
|
46
|
+
CONTROL_PLAN_FILE = "control-plan.json"
|
|
47
|
+
CONTROL_PREFLIGHT_FILE = "control-preflight.json"
|
|
48
|
+
SCHEMA_VERSION = 1 # the ecosystem-wide contract version (strict `!=` major check)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PreflightOutcome:
|
|
53
|
+
"""The result of one preflight run: the envelope block (``status`` always
|
|
54
|
+
present), the yellow CLI warning lines, and the copied artifact path (``None``
|
|
55
|
+
when reporting is disabled — nothing pretends an artifact exists)."""
|
|
56
|
+
|
|
57
|
+
block: dict
|
|
58
|
+
warnings: list[str] = field(default_factory=list)
|
|
59
|
+
artifact_path: Path | None = None
|
|
60
|
+
should_block: bool = False
|
|
61
|
+
block_reasons: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_request(cfg: ToolgraphConfig, *, run_id: str, agent: str) -> dict:
|
|
65
|
+
"""The request contract (``contracts/preflight-request.schema.json``) — the
|
|
66
|
+
serialized form of the argv sent to ``toolgraph preflight``."""
|
|
67
|
+
return {
|
|
68
|
+
"schema_version": SCHEMA_VERSION,
|
|
69
|
+
"run_id": run_id,
|
|
70
|
+
"agent": agent,
|
|
71
|
+
"candidate_tools": list(cfg.candidate_tools),
|
|
72
|
+
"policy_profile": cfg.profile,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _request_argv(cfg: ToolgraphConfig, request: dict, out_path: Path) -> list[str]:
|
|
77
|
+
"""argv only — no stdin. ``command`` is the invocation seam tests override."""
|
|
78
|
+
return [
|
|
79
|
+
*cfg.command,
|
|
80
|
+
request["agent"],
|
|
81
|
+
*request["candidate_tools"],
|
|
82
|
+
"--profile",
|
|
83
|
+
request["policy_profile"],
|
|
84
|
+
"--run-id",
|
|
85
|
+
request["run_id"],
|
|
86
|
+
"--out",
|
|
87
|
+
str(out_path),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _error_block(profile: str, status: str, *, mode: str = "advisory") -> dict:
|
|
92
|
+
"""A minimal envelope block for a failed/unresolved preflight (schema only
|
|
93
|
+
requires ``status`` off the ``ok`` path)."""
|
|
94
|
+
return {"status": status, "mode": mode, "profile": profile}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_preflight(
|
|
98
|
+
cfg: ToolgraphConfig,
|
|
99
|
+
*,
|
|
100
|
+
run_id: str,
|
|
101
|
+
agent_ids: list[str],
|
|
102
|
+
out_dir: Path | None,
|
|
103
|
+
control_plan: dict | bytes | None = None,
|
|
104
|
+
) -> PreflightOutcome | None:
|
|
105
|
+
"""Run advisory preflight once for the lead mapped agent. ``None`` when
|
|
106
|
+
disabled; otherwise NEVER raises — every failure degrades to a warning + a
|
|
107
|
+
``status`` block and the caller continues the run.
|
|
108
|
+
|
|
109
|
+
``out_dir`` is the run directory (report enabled) or ``None`` (report
|
|
110
|
+
disabled) — in the latter case the subprocess still runs (its identity
|
|
111
|
+
warning is the whole tier-1 value) but the artifact is parsed from a
|
|
112
|
+
tempdir and discarded, so ``artifact_path`` is ``None``.
|
|
113
|
+
"""
|
|
114
|
+
if not cfg.enabled:
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
if cfg.control_plan_enabled:
|
|
118
|
+
if control_plan is None:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"control-plan preflight enabled without a control plan"
|
|
121
|
+
)
|
|
122
|
+
return _run_control_preflight(
|
|
123
|
+
cfg,
|
|
124
|
+
run_id=run_id,
|
|
125
|
+
agent_ids=agent_ids,
|
|
126
|
+
out_dir=out_dir,
|
|
127
|
+
control_plan=control_plan,
|
|
128
|
+
)
|
|
129
|
+
if cfg.mode == "strict":
|
|
130
|
+
return _run_strict_preflight(
|
|
131
|
+
cfg, run_id=run_id, agent_ids=agent_ids, out_dir=out_dir
|
|
132
|
+
)
|
|
133
|
+
return _run_preflight_inner(
|
|
134
|
+
cfg, run_id=run_id, agent_ids=agent_ids, out_dir=out_dir
|
|
135
|
+
)
|
|
136
|
+
except Exception as exc: # last-resort net — preflight must never abort a run
|
|
137
|
+
logger.warning("preflight failed unexpectedly; continuing run: %s", exc)
|
|
138
|
+
strict = cfg.mode == "strict"
|
|
139
|
+
block = _error_block(cfg.profile, "error", mode=cfg.mode)
|
|
140
|
+
if strict:
|
|
141
|
+
block.update(
|
|
142
|
+
enforcement_decision="block",
|
|
143
|
+
evaluation_count=0,
|
|
144
|
+
block_count=1,
|
|
145
|
+
warning_count=0,
|
|
146
|
+
)
|
|
147
|
+
return PreflightOutcome(
|
|
148
|
+
block=block,
|
|
149
|
+
warnings=[f"preflight failed unexpectedly: {exc}"],
|
|
150
|
+
should_block=strict,
|
|
151
|
+
block_reasons=("INTERNAL_ERROR",) if strict else (),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_CONTROL_FINDING_CODES = frozenset(
|
|
156
|
+
{
|
|
157
|
+
"START_NODE_COUNT",
|
|
158
|
+
"END_NODE_COUNT",
|
|
159
|
+
"UNKNOWN_NODE_REFERENCE",
|
|
160
|
+
"UNREACHABLE_NODE",
|
|
161
|
+
"NO_TERMINAL_PATH",
|
|
162
|
+
"CONTROL_CYCLE_UNSUPPORTED",
|
|
163
|
+
"MODEL_FANOUT_UNBOUNDED",
|
|
164
|
+
"CALL_BOUND_TOO_LOW",
|
|
165
|
+
"PARALLEL_BOUND_TOO_LOW",
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _canonical_json(value: object) -> bytes:
|
|
171
|
+
return (
|
|
172
|
+
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
173
|
+
+ "\n"
|
|
174
|
+
).encode("utf-8")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def canonical_control_plan(value: object) -> bytes:
|
|
178
|
+
"""Return the canonical bytes shared by preflight and durable workflows."""
|
|
179
|
+
return _canonical_json(value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _control_request_argv(
|
|
183
|
+
cfg: ToolgraphConfig, plan_path: Path, out_path: Path
|
|
184
|
+
) -> list[str]:
|
|
185
|
+
return [
|
|
186
|
+
*cfg.control_plan_command,
|
|
187
|
+
str(plan_path),
|
|
188
|
+
"--profile",
|
|
189
|
+
cfg.profile,
|
|
190
|
+
"--out",
|
|
191
|
+
str(out_path),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _invoke_control_toolgraph(
|
|
196
|
+
cfg: ToolgraphConfig, plan_path: Path, out_path: Path
|
|
197
|
+
) -> tuple[str | None, str]:
|
|
198
|
+
argv = _control_request_argv(cfg, plan_path, out_path)
|
|
199
|
+
try:
|
|
200
|
+
proc = _sync_run( # owned-group runner: a timeout kills the whole tree
|
|
201
|
+
argv, capture_output=True, text=True, errors="replace",
|
|
202
|
+
timeout=cfg.timeout_seconds,
|
|
203
|
+
)
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
return (
|
|
206
|
+
"error",
|
|
207
|
+
f"toolgraph binary {cfg.control_plan_command[0]!r} not found in PATH",
|
|
208
|
+
)
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
return "timeout", f"control preflight timed out after {cfg.timeout_seconds}s"
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
return (
|
|
213
|
+
"error",
|
|
214
|
+
f"control preflight could not launch {cfg.control_plan_command[0]!r}: {exc}",
|
|
215
|
+
)
|
|
216
|
+
if proc.returncode != 0:
|
|
217
|
+
snippet = (proc.stderr or "").strip()[:200]
|
|
218
|
+
return "error", f"control preflight exited {proc.returncode}: {snippet}"
|
|
219
|
+
return None, ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _control_malformed_reason(
|
|
223
|
+
doc: object, *, plan: dict, plan_digest: str, profile: str, run_id: str
|
|
224
|
+
) -> str | None:
|
|
225
|
+
if not isinstance(doc, dict):
|
|
226
|
+
return "artifact is not an object"
|
|
227
|
+
expected = {
|
|
228
|
+
"schema_version": SCHEMA_VERSION,
|
|
229
|
+
"kind": "toolgraph.control-preflight",
|
|
230
|
+
"run_id": run_id,
|
|
231
|
+
"profile": profile,
|
|
232
|
+
"mode": "advisory",
|
|
233
|
+
"plan_digest": plan_digest,
|
|
234
|
+
}
|
|
235
|
+
for key, value in expected.items():
|
|
236
|
+
if doc.get(key) != value:
|
|
237
|
+
return f"{key} does not match the control plan request"
|
|
238
|
+
if not isinstance(doc.get("created_at"), str) or not doc["created_at"]:
|
|
239
|
+
return "created_at is empty"
|
|
240
|
+
graph_state = doc.get("graph_state")
|
|
241
|
+
if not isinstance(graph_state, dict):
|
|
242
|
+
return "graph_state is not an object"
|
|
243
|
+
if set(graph_state) != {"instance_id", "generation"}:
|
|
244
|
+
return "graph_state has an unexpected shape"
|
|
245
|
+
if (
|
|
246
|
+
not isinstance(graph_state.get("instance_id"), str)
|
|
247
|
+
or not graph_state["instance_id"]
|
|
248
|
+
):
|
|
249
|
+
return "graph_state.instance_id is empty"
|
|
250
|
+
generation = graph_state.get("generation")
|
|
251
|
+
if (
|
|
252
|
+
not isinstance(generation, int)
|
|
253
|
+
or isinstance(generation, bool)
|
|
254
|
+
or generation < 0
|
|
255
|
+
):
|
|
256
|
+
return "graph_state.generation is not a non-negative integer"
|
|
257
|
+
|
|
258
|
+
expected_principals = {
|
|
259
|
+
row["id"]: row["candidate_tools"] for row in plan["principals"]
|
|
260
|
+
}
|
|
261
|
+
evaluations = doc.get("evaluations")
|
|
262
|
+
if not isinstance(evaluations, list) or len(evaluations) != len(
|
|
263
|
+
expected_principals
|
|
264
|
+
):
|
|
265
|
+
return "evaluation coverage does not match plan principals"
|
|
266
|
+
seen: set[str] = set()
|
|
267
|
+
producer_warn = False
|
|
268
|
+
for row in evaluations:
|
|
269
|
+
if not isinstance(row, dict) or not isinstance(row.get("principal"), str):
|
|
270
|
+
return "evaluation row has no principal"
|
|
271
|
+
principal = row["principal"]
|
|
272
|
+
if principal in seen or principal not in expected_principals:
|
|
273
|
+
return "evaluation principals are duplicated or unknown"
|
|
274
|
+
seen.add(principal)
|
|
275
|
+
if not isinstance(row.get("agent_found"), bool):
|
|
276
|
+
return "evaluation agent_found is not boolean"
|
|
277
|
+
eligible = row.get("eligible")
|
|
278
|
+
rejected = row.get("rejected")
|
|
279
|
+
if not isinstance(eligible, list) or not all(
|
|
280
|
+
isinstance(item, str) for item in eligible
|
|
281
|
+
):
|
|
282
|
+
return "evaluation eligible is not a string list"
|
|
283
|
+
if not isinstance(rejected, list):
|
|
284
|
+
return "evaluation rejected is not a list"
|
|
285
|
+
for rejected_row in rejected:
|
|
286
|
+
if (
|
|
287
|
+
not isinstance(rejected_row, dict)
|
|
288
|
+
or rejected_row.get("reason") not in _KNOWN_REASONS
|
|
289
|
+
or not isinstance(rejected_row.get("candidate"), str)
|
|
290
|
+
):
|
|
291
|
+
return "evaluation rejected row is malformed"
|
|
292
|
+
if rejected_row["reason"] == "AMBIGUOUS_TOOL" and not isinstance(
|
|
293
|
+
rejected_row.get("candidates"), list
|
|
294
|
+
):
|
|
295
|
+
return "ambiguous rejected row has no candidates"
|
|
296
|
+
if rejected_row["reason"] in {
|
|
297
|
+
"DENY_VIOLATION",
|
|
298
|
+
"DENY_GOVERNED",
|
|
299
|
+
} and not isinstance(rejected_row.get("paths"), list):
|
|
300
|
+
return "deny rejected row has no evidence paths"
|
|
301
|
+
if row["agent_found"]:
|
|
302
|
+
accounted = [
|
|
303
|
+
*eligible,
|
|
304
|
+
*(rejected_row["candidate"] for rejected_row in rejected),
|
|
305
|
+
]
|
|
306
|
+
if Counter(accounted) != Counter(expected_principals[principal]):
|
|
307
|
+
return "evaluation candidate coverage does not match the plan"
|
|
308
|
+
elif eligible or rejected:
|
|
309
|
+
return "unresolved principal shape is inconsistent"
|
|
310
|
+
producer_warn = producer_warn or not row["agent_found"] or bool(rejected)
|
|
311
|
+
if seen != set(expected_principals):
|
|
312
|
+
return "evaluation coverage does not match plan principals"
|
|
313
|
+
|
|
314
|
+
findings = doc.get("findings")
|
|
315
|
+
if not isinstance(findings, list):
|
|
316
|
+
return "findings is not a list"
|
|
317
|
+
if any(
|
|
318
|
+
not isinstance(row, dict) or row.get("code") not in _CONTROL_FINDING_CODES
|
|
319
|
+
for row in findings
|
|
320
|
+
):
|
|
321
|
+
return "finding has an unknown code"
|
|
322
|
+
producer_warn = producer_warn or bool(findings)
|
|
323
|
+
expected_decision = "advisory_warn" if producer_warn else "advisory_allow"
|
|
324
|
+
if doc.get("decision") != expected_decision:
|
|
325
|
+
return "decision is inconsistent with evaluations and findings"
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _control_unavailable_outcome(
|
|
330
|
+
cfg: ToolgraphConfig, *, status: str, message: str
|
|
331
|
+
) -> PreflightOutcome:
|
|
332
|
+
strict = cfg.mode == "strict"
|
|
333
|
+
action = _policy_action(cfg, "TOOLGRAPH_UNAVAILABLE")
|
|
334
|
+
should_block = strict and action == "block"
|
|
335
|
+
block = _error_block(cfg.profile, status, mode=cfg.mode)
|
|
336
|
+
if strict:
|
|
337
|
+
block.update(
|
|
338
|
+
enforcement_decision="block" if should_block else "allow",
|
|
339
|
+
evaluation_count=0,
|
|
340
|
+
block_count=1 if should_block else 0,
|
|
341
|
+
warning_count=0 if should_block else 1,
|
|
342
|
+
)
|
|
343
|
+
return PreflightOutcome(
|
|
344
|
+
block=block,
|
|
345
|
+
warnings=[message],
|
|
346
|
+
should_block=should_block,
|
|
347
|
+
block_reasons=("TOOLGRAPH_UNAVAILABLE",) if should_block else (),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _run_control_preflight(
|
|
352
|
+
cfg: ToolgraphConfig,
|
|
353
|
+
*,
|
|
354
|
+
run_id: str,
|
|
355
|
+
agent_ids: list[str],
|
|
356
|
+
out_dir: Path | None,
|
|
357
|
+
control_plan: dict | bytes,
|
|
358
|
+
) -> PreflightOutcome:
|
|
359
|
+
"""Consume one Toolgraph batch verdict and optionally enforce it strictly."""
|
|
360
|
+
from syncmill.report import sha256_digest
|
|
361
|
+
|
|
362
|
+
if isinstance(control_plan, bytes):
|
|
363
|
+
raw_plan = control_plan
|
|
364
|
+
try:
|
|
365
|
+
decoded_plan = json.loads(raw_plan)
|
|
366
|
+
except (UnicodeDecodeError, ValueError, TypeError, RecursionError) as exc:
|
|
367
|
+
raise ValueError("stored control plan is not valid JSON") from exc
|
|
368
|
+
if not isinstance(decoded_plan, dict):
|
|
369
|
+
raise ValueError("stored control plan is not a JSON object")
|
|
370
|
+
else:
|
|
371
|
+
decoded_plan = control_plan
|
|
372
|
+
raw_plan = _canonical_json(decoded_plan)
|
|
373
|
+
plan_digest = sha256_digest(raw_plan)
|
|
374
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
375
|
+
plan_path = Path(tmp) / CONTROL_PLAN_FILE
|
|
376
|
+
result_path = Path(tmp) / CONTROL_PREFLIGHT_FILE
|
|
377
|
+
plan_path.write_bytes(raw_plan)
|
|
378
|
+
status, message = _invoke_control_toolgraph(cfg, plan_path, result_path)
|
|
379
|
+
if status is not None:
|
|
380
|
+
return _control_unavailable_outcome(cfg, status=status, message=message)
|
|
381
|
+
try:
|
|
382
|
+
raw_result = result_path.read_bytes()
|
|
383
|
+
doc = json.loads(raw_result)
|
|
384
|
+
except (OSError, ValueError) as exc:
|
|
385
|
+
malformed = f"control preflight produced unreadable output: {exc}"
|
|
386
|
+
if cfg.mode == "strict":
|
|
387
|
+
return PreflightOutcome(
|
|
388
|
+
block={
|
|
389
|
+
**_error_block(cfg.profile, "error", mode="strict"),
|
|
390
|
+
"enforcement_decision": "block",
|
|
391
|
+
"evaluation_count": 0,
|
|
392
|
+
"block_count": 1,
|
|
393
|
+
"warning_count": 0,
|
|
394
|
+
},
|
|
395
|
+
warnings=[malformed],
|
|
396
|
+
should_block=True,
|
|
397
|
+
block_reasons=("CONTRACT_INVALID",),
|
|
398
|
+
)
|
|
399
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), [malformed])
|
|
400
|
+
|
|
401
|
+
malformed = _control_malformed_reason(
|
|
402
|
+
doc,
|
|
403
|
+
plan=decoded_plan,
|
|
404
|
+
plan_digest=plan_digest,
|
|
405
|
+
profile=cfg.profile,
|
|
406
|
+
run_id=run_id,
|
|
407
|
+
)
|
|
408
|
+
if malformed is not None:
|
|
409
|
+
message = f"control preflight artifact is invalid ({malformed})"
|
|
410
|
+
if cfg.mode == "strict":
|
|
411
|
+
return PreflightOutcome(
|
|
412
|
+
block={
|
|
413
|
+
**_error_block(cfg.profile, "error", mode="strict"),
|
|
414
|
+
"enforcement_decision": "block",
|
|
415
|
+
"evaluation_count": 0,
|
|
416
|
+
"block_count": 1,
|
|
417
|
+
"warning_count": 0,
|
|
418
|
+
},
|
|
419
|
+
warnings=[message],
|
|
420
|
+
should_block=True,
|
|
421
|
+
block_reasons=("CONTRACT_INVALID",),
|
|
422
|
+
)
|
|
423
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), [message])
|
|
424
|
+
|
|
425
|
+
warnings: list[str] = []
|
|
426
|
+
blocking: list[str] = []
|
|
427
|
+
warning_reasons: list[str] = []
|
|
428
|
+
unmapped_agents = [agent for agent in agent_ids if agent not in cfg.agent_map]
|
|
429
|
+
if unmapped_agents:
|
|
430
|
+
reason = "AGENT_MAPPING_MISSING"
|
|
431
|
+
target = (
|
|
432
|
+
blocking
|
|
433
|
+
if cfg.mode == "strict" and _policy_action(cfg, reason) == "block"
|
|
434
|
+
else warning_reasons
|
|
435
|
+
)
|
|
436
|
+
target.append(reason)
|
|
437
|
+
warnings.append(
|
|
438
|
+
"control preflight has unmapped syncmill agents: "
|
|
439
|
+
+ ", ".join(unmapped_agents)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Derived from the agents that ACTUALLY had no mapping, not from how the
|
|
443
|
+
# identity happens to be spelled. Deciding by prefix let a deliberately
|
|
444
|
+
# mapped identity pass for a sentinel: Toolgraph reporting it missing
|
|
445
|
+
# then suppressed AGENT_NOT_FOUND, and AGENT_MAPPING_MISSING could not
|
|
446
|
+
# fire either because the agent IS mapped — strict mode dispatching with
|
|
447
|
+
# neither reason recorded, the outcome it exists to prevent. The config
|
|
448
|
+
# now refuses an identity of the exact synthetic shape, so the two can no
|
|
449
|
+
# longer collide; this is what keeps the classification right if that
|
|
450
|
+
# reservation is ever relaxed, and it is the half that says what the set
|
|
451
|
+
# actually means.
|
|
452
|
+
sentinel_principals = {
|
|
453
|
+
unmapped_principal_id(agent) for agent in unmapped_agents
|
|
454
|
+
}
|
|
455
|
+
for evaluation in doc["evaluations"]:
|
|
456
|
+
principal = evaluation["principal"]
|
|
457
|
+
if not evaluation["agent_found"] and principal not in sentinel_principals:
|
|
458
|
+
reason = "AGENT_NOT_FOUND"
|
|
459
|
+
target = (
|
|
460
|
+
blocking
|
|
461
|
+
if cfg.mode == "strict" and _policy_action(cfg, reason) == "block"
|
|
462
|
+
else warning_reasons
|
|
463
|
+
)
|
|
464
|
+
target.append(reason)
|
|
465
|
+
for rejected in evaluation["rejected"]:
|
|
466
|
+
reason = rejected["reason"]
|
|
467
|
+
target = (
|
|
468
|
+
blocking
|
|
469
|
+
if cfg.mode == "strict" and _policy_action(cfg, reason) == "block"
|
|
470
|
+
else warning_reasons
|
|
471
|
+
)
|
|
472
|
+
target.append(reason)
|
|
473
|
+
if doc["findings"]:
|
|
474
|
+
finding_codes = sorted({row["code"] for row in doc["findings"]})
|
|
475
|
+
warnings.append("control-plan findings: " + ", ".join(finding_codes))
|
|
476
|
+
if cfg.mode == "strict":
|
|
477
|
+
blocking.append("CONTROL_PLAN_FINDING")
|
|
478
|
+
else:
|
|
479
|
+
warning_reasons.append("CONTROL_PLAN_FINDING")
|
|
480
|
+
if blocking or warning_reasons:
|
|
481
|
+
warnings.append(
|
|
482
|
+
f"control preflight: block={sorted(set(blocking)) or 'none'}, "
|
|
483
|
+
f"warn={sorted(set(warning_reasons)) or 'none'}"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if out_dir is None and cfg.mode == "strict":
|
|
487
|
+
blocking.append("EVIDENCE_PERSIST_UNAVAILABLE")
|
|
488
|
+
warnings.append(
|
|
489
|
+
"strict control preflight requires a report artifact directory"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
artifact_path: Path | None = None
|
|
493
|
+
if out_dir is not None:
|
|
494
|
+
written: list[Path] = []
|
|
495
|
+
try:
|
|
496
|
+
written.append(_atomic_write(raw_plan, out_dir / CONTROL_PLAN_FILE))
|
|
497
|
+
artifact_path = _atomic_write(
|
|
498
|
+
raw_result, out_dir / CONTROL_PREFLIGHT_FILE
|
|
499
|
+
)
|
|
500
|
+
written.append(artifact_path)
|
|
501
|
+
except (OSError, ValueError) as exc:
|
|
502
|
+
warnings.append(
|
|
503
|
+
f"control preflight evidence could not be persisted: {exc}"
|
|
504
|
+
)
|
|
505
|
+
if cfg.mode == "strict":
|
|
506
|
+
blocking.append("EVIDENCE_PERSIST_FAILED")
|
|
507
|
+
for stale in written:
|
|
508
|
+
try:
|
|
509
|
+
stale.unlink(missing_ok=True)
|
|
510
|
+
except OSError:
|
|
511
|
+
pass
|
|
512
|
+
artifact_path = None
|
|
513
|
+
|
|
514
|
+
should_block = cfg.mode == "strict" and bool(blocking)
|
|
515
|
+
persistence_error = out_dir is not None and artifact_path is None
|
|
516
|
+
status = (
|
|
517
|
+
"error"
|
|
518
|
+
if (
|
|
519
|
+
{
|
|
520
|
+
"EVIDENCE_PERSIST_UNAVAILABLE",
|
|
521
|
+
"EVIDENCE_PERSIST_FAILED",
|
|
522
|
+
}
|
|
523
|
+
& set(blocking)
|
|
524
|
+
or persistence_error
|
|
525
|
+
)
|
|
526
|
+
else "ok"
|
|
527
|
+
)
|
|
528
|
+
warning_count = len(set(warning_reasons)) + len(doc["findings"])
|
|
529
|
+
block: dict = {
|
|
530
|
+
"status": status,
|
|
531
|
+
"mode": cfg.mode,
|
|
532
|
+
"profile": cfg.profile,
|
|
533
|
+
"graph_generation": doc["graph_state"]["generation"],
|
|
534
|
+
"graph_state": doc["graph_state"],
|
|
535
|
+
"warning_count": warning_count,
|
|
536
|
+
"evaluation_count": len(doc["evaluations"]),
|
|
537
|
+
"producer_decision": doc["decision"],
|
|
538
|
+
}
|
|
539
|
+
if artifact_path is not None:
|
|
540
|
+
block["artifact"] = CONTROL_PREFLIGHT_FILE
|
|
541
|
+
block["digest"] = sha256_digest(raw_result)
|
|
542
|
+
block["control_plan"] = {
|
|
543
|
+
"artifact": CONTROL_PLAN_FILE,
|
|
544
|
+
"digest": plan_digest,
|
|
545
|
+
}
|
|
546
|
+
if cfg.mode == "strict":
|
|
547
|
+
block.update(
|
|
548
|
+
enforcement_decision="block" if should_block else "allow",
|
|
549
|
+
block_count=len(set(blocking)),
|
|
550
|
+
)
|
|
551
|
+
return PreflightOutcome(
|
|
552
|
+
block=block,
|
|
553
|
+
warnings=warnings,
|
|
554
|
+
artifact_path=artifact_path,
|
|
555
|
+
should_block=should_block,
|
|
556
|
+
block_reasons=tuple(sorted(set(blocking))),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
_KNOWN_REASONS = frozenset(
|
|
561
|
+
{
|
|
562
|
+
"TOOL_NOT_FOUND",
|
|
563
|
+
"AMBIGUOUS_TOOL",
|
|
564
|
+
"NOT_GRANTED",
|
|
565
|
+
"DENY_VIOLATION",
|
|
566
|
+
"DENY_GOVERNED",
|
|
567
|
+
"DRIFTED",
|
|
568
|
+
"UNMAPPED",
|
|
569
|
+
}
|
|
570
|
+
)
|
|
571
|
+
_UNKNOWN_REASONS = frozenset({"TOOL_NOT_FOUND", "AMBIGUOUS_TOOL"})
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _policy_action(cfg: ToolgraphConfig, reason: str) -> str:
|
|
575
|
+
if reason in _UNKNOWN_REASONS or reason in {
|
|
576
|
+
"AGENT_MAPPING_MISSING",
|
|
577
|
+
"AGENT_NOT_FOUND",
|
|
578
|
+
}:
|
|
579
|
+
return cfg.unknown_policy
|
|
580
|
+
if reason == "DRIFTED":
|
|
581
|
+
return cfg.drift_policy
|
|
582
|
+
if reason == "TOOLGRAPH_UNAVAILABLE":
|
|
583
|
+
return cfg.unavailable_policy
|
|
584
|
+
return "block"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _run_strict_preflight(
|
|
588
|
+
cfg: ToolgraphConfig,
|
|
589
|
+
*,
|
|
590
|
+
run_id: str,
|
|
591
|
+
agent_ids: list[str],
|
|
592
|
+
out_dir: Path | None,
|
|
593
|
+
) -> PreflightOutcome:
|
|
594
|
+
"""Evaluate every participating identity and enforce the selected profile.
|
|
595
|
+
|
|
596
|
+
Toolgraph remains an advisory producer. This consumer-owned aggregate is
|
|
597
|
+
the P3.1 enforcement boundary; invalid evidence always fails closed.
|
|
598
|
+
"""
|
|
599
|
+
from syncmill.report import sha256_digest
|
|
600
|
+
|
|
601
|
+
warnings: list[str] = []
|
|
602
|
+
evaluations: list[dict] = []
|
|
603
|
+
blocking: list[str] = []
|
|
604
|
+
generations: list[int] = []
|
|
605
|
+
persisted: list[tuple[bytes, str]] = []
|
|
606
|
+
|
|
607
|
+
# Preserve lineup order while evaluating a shared Toolgraph identity once.
|
|
608
|
+
grouped: dict[str, list[str]] = {}
|
|
609
|
+
for agent_id in agent_ids:
|
|
610
|
+
mapped = cfg.agent_map.get(agent_id)
|
|
611
|
+
if mapped is None:
|
|
612
|
+
reason = "AGENT_MAPPING_MISSING"
|
|
613
|
+
action = _policy_action(cfg, reason)
|
|
614
|
+
evaluations.append(
|
|
615
|
+
{
|
|
616
|
+
"syncmill_agents": [agent_id],
|
|
617
|
+
"toolgraph_agent": None,
|
|
618
|
+
"status": "unresolved_identity",
|
|
619
|
+
"blocking_reasons": [reason] if action == "block" else [],
|
|
620
|
+
"warning_reasons": [reason] if action == "warn" else [],
|
|
621
|
+
}
|
|
622
|
+
)
|
|
623
|
+
warnings.append(f"agent {agent_id!r} has no toolgraph identity mapping")
|
|
624
|
+
if action == "block":
|
|
625
|
+
blocking.append(reason)
|
|
626
|
+
continue
|
|
627
|
+
grouped.setdefault(mapped, []).append(agent_id)
|
|
628
|
+
|
|
629
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
630
|
+
for index, (mapped, syncmill_agents) in enumerate(grouped.items()):
|
|
631
|
+
request = _build_request(cfg, run_id=run_id, agent=mapped)
|
|
632
|
+
out_path = Path(tmp) / f"preflight-{index:03d}.json"
|
|
633
|
+
operational = _invoke_toolgraph(cfg, request, out_path)
|
|
634
|
+
if operational[0] is not None:
|
|
635
|
+
status, message = operational[0], operational[1]
|
|
636
|
+
reason = "TOOLGRAPH_UNAVAILABLE"
|
|
637
|
+
action = _policy_action(cfg, reason)
|
|
638
|
+
evaluations.append(
|
|
639
|
+
{
|
|
640
|
+
"syncmill_agents": syncmill_agents,
|
|
641
|
+
"toolgraph_agent": mapped,
|
|
642
|
+
"status": status,
|
|
643
|
+
"blocking_reasons": [reason] if action == "block" else [],
|
|
644
|
+
"warning_reasons": [reason] if action == "warn" else [],
|
|
645
|
+
}
|
|
646
|
+
)
|
|
647
|
+
warnings.append(message)
|
|
648
|
+
if action == "block":
|
|
649
|
+
blocking.append(reason)
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
raw = out_path.read_bytes()
|
|
654
|
+
doc = json.loads(raw)
|
|
655
|
+
except (OSError, ValueError) as exc:
|
|
656
|
+
reason = "CONTRACT_INVALID"
|
|
657
|
+
blocking.append(reason)
|
|
658
|
+
warnings.append(f"preflight produced unreadable output: {exc}")
|
|
659
|
+
evaluations.append(
|
|
660
|
+
{
|
|
661
|
+
"syncmill_agents": syncmill_agents,
|
|
662
|
+
"toolgraph_agent": mapped,
|
|
663
|
+
"status": "error",
|
|
664
|
+
"blocking_reasons": [reason],
|
|
665
|
+
"warning_reasons": [],
|
|
666
|
+
}
|
|
667
|
+
)
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
malformed = _strict_malformed_reason(doc, request)
|
|
671
|
+
if malformed is not None:
|
|
672
|
+
reason = "CONTRACT_INVALID"
|
|
673
|
+
blocking.append(reason)
|
|
674
|
+
warnings.append(f"preflight artifact is invalid ({malformed})")
|
|
675
|
+
evaluations.append(
|
|
676
|
+
{
|
|
677
|
+
"syncmill_agents": syncmill_agents,
|
|
678
|
+
"toolgraph_agent": mapped,
|
|
679
|
+
"status": "error",
|
|
680
|
+
"blocking_reasons": [reason],
|
|
681
|
+
"warning_reasons": [],
|
|
682
|
+
}
|
|
683
|
+
)
|
|
684
|
+
continue
|
|
685
|
+
|
|
686
|
+
generations.append(doc["graph_generation"])
|
|
687
|
+
row_blocks: list[str] = []
|
|
688
|
+
row_warnings: list[str] = []
|
|
689
|
+
if doc["decision"] == "unresolved_identity":
|
|
690
|
+
reason = "AGENT_NOT_FOUND"
|
|
691
|
+
(
|
|
692
|
+
row_blocks
|
|
693
|
+
if _policy_action(cfg, reason) == "block"
|
|
694
|
+
else row_warnings
|
|
695
|
+
).append(reason)
|
|
696
|
+
else:
|
|
697
|
+
for rejected in doc["rejected"]:
|
|
698
|
+
reason = rejected["reason"]
|
|
699
|
+
(
|
|
700
|
+
row_blocks
|
|
701
|
+
if _policy_action(cfg, reason) == "block"
|
|
702
|
+
else row_warnings
|
|
703
|
+
).append(reason)
|
|
704
|
+
blocking.extend(row_blocks)
|
|
705
|
+
if row_blocks or row_warnings:
|
|
706
|
+
warnings.append(
|
|
707
|
+
f"preflight for {mapped!r}: block={row_blocks or 'none'}, "
|
|
708
|
+
f"warn={row_warnings or 'none'}"
|
|
709
|
+
)
|
|
710
|
+
rel = f"preflight/eval-{index:03d}.json"
|
|
711
|
+
persisted.append((raw, rel))
|
|
712
|
+
evaluations.append(
|
|
713
|
+
{
|
|
714
|
+
"syncmill_agents": syncmill_agents,
|
|
715
|
+
"toolgraph_agent": mapped,
|
|
716
|
+
"status": (
|
|
717
|
+
"unresolved_identity"
|
|
718
|
+
if doc["decision"] == "unresolved_identity"
|
|
719
|
+
else "ok"
|
|
720
|
+
),
|
|
721
|
+
"producer_decision": doc["decision"],
|
|
722
|
+
"graph_generation": doc["graph_generation"],
|
|
723
|
+
"artifact": rel,
|
|
724
|
+
"digest": sha256_digest(raw),
|
|
725
|
+
"blocking_reasons": row_blocks,
|
|
726
|
+
"warning_reasons": row_warnings,
|
|
727
|
+
}
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
unique_generations = set(generations)
|
|
731
|
+
if len(unique_generations) > 1:
|
|
732
|
+
blocking.append("GRAPH_GENERATION_MISMATCH")
|
|
733
|
+
warnings.append(
|
|
734
|
+
"strict preflight identities were evaluated at different graph generations"
|
|
735
|
+
)
|
|
736
|
+
if out_dir is None:
|
|
737
|
+
blocking.append("EVIDENCE_PERSIST_UNAVAILABLE")
|
|
738
|
+
warnings.append(
|
|
739
|
+
"strict preflight requires a writable report artifact directory"
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# Persist the per-eval artifacts BEFORE the decision/manifest are computed,
|
|
743
|
+
# so a persist failure is itself a blocking reason recorded IN the manifest.
|
|
744
|
+
# The old order froze the manifest bytes first and flipped only the RETURNED
|
|
745
|
+
# block on failure — the on-disk decision could read `allow` for a run that
|
|
746
|
+
# was correctly blocked.
|
|
747
|
+
persisted_paths: list[Path] = []
|
|
748
|
+
if out_dir is not None:
|
|
749
|
+
persist_error: str | None = None
|
|
750
|
+
written: set[str] = set()
|
|
751
|
+
for raw, rel in persisted:
|
|
752
|
+
try:
|
|
753
|
+
persisted_paths.append(_atomic_write(raw, out_dir / rel))
|
|
754
|
+
written.add(rel)
|
|
755
|
+
except (OSError, ValueError) as exc:
|
|
756
|
+
persist_error = str(exc)
|
|
757
|
+
break
|
|
758
|
+
if persist_error is not None:
|
|
759
|
+
warnings.append(
|
|
760
|
+
f"strict preflight evidence could not be persisted: {persist_error}"
|
|
761
|
+
)
|
|
762
|
+
blocking.append("EVIDENCE_PERSIST_FAILED")
|
|
763
|
+
# The manifest must reference only files that actually exist: a
|
|
764
|
+
# mid-loop failure leaves earlier artifacts on disk but not the
|
|
765
|
+
# failed/remaining ones — strip their references (the same
|
|
766
|
+
# no-artifact shape the error/timeout rows use) so a consumer can
|
|
767
|
+
# verify every reference the aggregate makes.
|
|
768
|
+
for row in evaluations:
|
|
769
|
+
if row.get("artifact") is not None and row["artifact"] not in written:
|
|
770
|
+
row.pop("artifact", None)
|
|
771
|
+
row.pop("digest", None)
|
|
772
|
+
|
|
773
|
+
should_block = bool(blocking)
|
|
774
|
+
statuses = {row["status"] for row in evaluations}
|
|
775
|
+
status = (
|
|
776
|
+
"error"
|
|
777
|
+
if "error" in statuses
|
|
778
|
+
else "timeout"
|
|
779
|
+
if "timeout" in statuses
|
|
780
|
+
else "unresolved_identity"
|
|
781
|
+
if "unresolved_identity" in statuses
|
|
782
|
+
else "ok"
|
|
783
|
+
)
|
|
784
|
+
if {
|
|
785
|
+
"GRAPH_GENERATION_MISMATCH",
|
|
786
|
+
"EVIDENCE_PERSIST_UNAVAILABLE",
|
|
787
|
+
"EVIDENCE_PERSIST_FAILED",
|
|
788
|
+
} & set(blocking):
|
|
789
|
+
status = "error"
|
|
790
|
+
manifest = {
|
|
791
|
+
"schema_version": 1,
|
|
792
|
+
"kind": "syncmill.toolgraph-enforcement",
|
|
793
|
+
"run_id": run_id,
|
|
794
|
+
"mode": "strict",
|
|
795
|
+
"profile": cfg.profile,
|
|
796
|
+
"policies": {
|
|
797
|
+
"unknown": cfg.unknown_policy,
|
|
798
|
+
"drift": cfg.drift_policy,
|
|
799
|
+
"unavailable": cfg.unavailable_policy,
|
|
800
|
+
},
|
|
801
|
+
"decision": "block" if should_block else "allow",
|
|
802
|
+
"evaluations": evaluations,
|
|
803
|
+
"block_reasons": sorted(set(blocking)),
|
|
804
|
+
}
|
|
805
|
+
raw_manifest = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()
|
|
806
|
+
block: dict = {
|
|
807
|
+
"status": status,
|
|
808
|
+
"mode": "strict",
|
|
809
|
+
"profile": cfg.profile,
|
|
810
|
+
"enforcement_decision": manifest["decision"],
|
|
811
|
+
"evaluation_count": len(evaluations),
|
|
812
|
+
# Count distinct blocking conditions; the manifest exposes the same
|
|
813
|
+
# deduplicated vocabulary in block_reasons.
|
|
814
|
+
"block_count": len(set(blocking)),
|
|
815
|
+
"warning_count": sum(len(row["warning_reasons"]) for row in evaluations),
|
|
816
|
+
}
|
|
817
|
+
if len(unique_generations) == 1:
|
|
818
|
+
block["graph_generation"] = next(iter(unique_generations))
|
|
819
|
+
|
|
820
|
+
# The manifest is written LAST, after every blocking reason (including a
|
|
821
|
+
# failed eval persist above) is final — the on-disk decision always matches
|
|
822
|
+
# the returned outcome.
|
|
823
|
+
artifact_path: Path | None = None
|
|
824
|
+
if out_dir is not None:
|
|
825
|
+
try:
|
|
826
|
+
artifact_path = _atomic_write(raw_manifest, out_dir / PREFLIGHT_FILE)
|
|
827
|
+
block["artifact"] = PREFLIGHT_FILE
|
|
828
|
+
block["digest"] = sha256_digest(raw_manifest)
|
|
829
|
+
except (OSError, ValueError) as exc:
|
|
830
|
+
warnings.append(f"strict preflight manifest could not be persisted: {exc}")
|
|
831
|
+
blocking.append("EVIDENCE_PERSIST_FAILED")
|
|
832
|
+
should_block = True
|
|
833
|
+
block.update(status="error", enforcement_decision="block")
|
|
834
|
+
block["block_count"] = len(set(blocking))
|
|
835
|
+
# Leave no partial evidence pile behind: raw producer docs whose
|
|
836
|
+
# decision reads advisory_allow, with no aggregate manifest, would
|
|
837
|
+
# misrepresent a blocked run. Best-effort — the block is already
|
|
838
|
+
# returned either way.
|
|
839
|
+
artifact_path = None
|
|
840
|
+
for stale in (*persisted_paths, out_dir / PREFLIGHT_FILE):
|
|
841
|
+
try:
|
|
842
|
+
stale.unlink(missing_ok=True)
|
|
843
|
+
except OSError:
|
|
844
|
+
pass
|
|
845
|
+
|
|
846
|
+
return PreflightOutcome(
|
|
847
|
+
block=block,
|
|
848
|
+
warnings=warnings,
|
|
849
|
+
artifact_path=artifact_path,
|
|
850
|
+
should_block=should_block,
|
|
851
|
+
block_reasons=tuple(sorted(set(blocking))),
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _invoke_toolgraph(
|
|
856
|
+
cfg: ToolgraphConfig, request: dict, out_path: Path
|
|
857
|
+
) -> tuple[str | None, str]:
|
|
858
|
+
argv = _request_argv(cfg, request, out_path)
|
|
859
|
+
try:
|
|
860
|
+
proc = _sync_run( # owned-group runner: a timeout kills the whole tree
|
|
861
|
+
argv, capture_output=True, text=True, errors="replace",
|
|
862
|
+
timeout=cfg.timeout_seconds,
|
|
863
|
+
)
|
|
864
|
+
except FileNotFoundError:
|
|
865
|
+
return "error", f"toolgraph binary {cfg.command[0]!r} not found in PATH"
|
|
866
|
+
except subprocess.TimeoutExpired:
|
|
867
|
+
return "timeout", f"preflight timed out after {cfg.timeout_seconds}s"
|
|
868
|
+
except OSError as exc:
|
|
869
|
+
return "error", f"preflight could not launch {cfg.command[0]!r}: {exc}"
|
|
870
|
+
if proc.returncode != 0:
|
|
871
|
+
snippet = (proc.stderr or "").strip()[:200]
|
|
872
|
+
return "error", f"preflight exited {proc.returncode}: {snippet}"
|
|
873
|
+
return None, ""
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _strict_malformed_reason(doc: object, request: dict) -> str | None:
|
|
877
|
+
if not isinstance(doc, dict):
|
|
878
|
+
return "artifact is not an object"
|
|
879
|
+
expected = {
|
|
880
|
+
"schema_version": SCHEMA_VERSION,
|
|
881
|
+
"kind": "toolgraph.preflight",
|
|
882
|
+
"run_id": request["run_id"],
|
|
883
|
+
"agent": request["agent"],
|
|
884
|
+
"profile": request["policy_profile"],
|
|
885
|
+
"mode": "advisory",
|
|
886
|
+
}
|
|
887
|
+
for key, value in expected.items():
|
|
888
|
+
if doc.get(key) != value:
|
|
889
|
+
return f"{key} does not match the request"
|
|
890
|
+
reason = _malformed_reason(doc)
|
|
891
|
+
if reason is not None:
|
|
892
|
+
return reason
|
|
893
|
+
if not isinstance(doc.get("agent_found"), bool):
|
|
894
|
+
return "agent_found is not boolean"
|
|
895
|
+
eligible = doc.get("eligible")
|
|
896
|
+
rejected = doc.get("rejected")
|
|
897
|
+
if not isinstance(eligible, list) or not all(
|
|
898
|
+
isinstance(item, str) for item in eligible
|
|
899
|
+
):
|
|
900
|
+
return "eligible is not a string list"
|
|
901
|
+
if not isinstance(rejected, list):
|
|
902
|
+
return "rejected is not a list"
|
|
903
|
+
for row in rejected:
|
|
904
|
+
if not isinstance(row, dict) or row.get("reason") not in _KNOWN_REASONS:
|
|
905
|
+
return "rejected row has an unknown reason"
|
|
906
|
+
if not isinstance(row.get("candidate"), str):
|
|
907
|
+
return "rejected row has no candidate"
|
|
908
|
+
if row["reason"] == "AMBIGUOUS_TOOL" and not isinstance(
|
|
909
|
+
row.get("candidates"), list
|
|
910
|
+
):
|
|
911
|
+
return "ambiguous row has no candidates"
|
|
912
|
+
if row["reason"] in {"DENY_VIOLATION", "DENY_GOVERNED"} and not isinstance(
|
|
913
|
+
row.get("paths"), list
|
|
914
|
+
):
|
|
915
|
+
return "deny row has no evidence paths"
|
|
916
|
+
if not doc["agent_found"]:
|
|
917
|
+
if doc["decision"] != "unresolved_identity" or eligible or rejected:
|
|
918
|
+
return "unresolved identity shape is inconsistent"
|
|
919
|
+
return None
|
|
920
|
+
expected_decision = "advisory_warn" if rejected else "advisory_allow"
|
|
921
|
+
if doc["decision"] != expected_decision:
|
|
922
|
+
return "decision is inconsistent with rejected rows"
|
|
923
|
+
accounted = [*eligible, *(row["candidate"] for row in rejected)]
|
|
924
|
+
if Counter(accounted) != Counter(request["candidate_tools"]):
|
|
925
|
+
return "candidate coverage does not match the request"
|
|
926
|
+
return None
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _run_preflight_inner(
|
|
930
|
+
cfg: ToolgraphConfig,
|
|
931
|
+
*,
|
|
932
|
+
run_id: str,
|
|
933
|
+
agent_ids: list[str],
|
|
934
|
+
out_dir: Path | None,
|
|
935
|
+
) -> PreflightOutcome:
|
|
936
|
+
warnings: list[str] = []
|
|
937
|
+
|
|
938
|
+
# Map ids locally (pure dict lookups). Warn on every unmapped id; the lead
|
|
939
|
+
# (first matched) mapped agent is the identity we probe.
|
|
940
|
+
lead: str | None = None
|
|
941
|
+
for agent_id in agent_ids:
|
|
942
|
+
mapped = cfg.agent_map.get(agent_id)
|
|
943
|
+
if mapped is None:
|
|
944
|
+
warnings.append(
|
|
945
|
+
f"agent {agent_id!r} has no [integration.toolgraph.agent_map] "
|
|
946
|
+
"entry — skipped for preflight"
|
|
947
|
+
)
|
|
948
|
+
elif lead is None:
|
|
949
|
+
lead = mapped
|
|
950
|
+
|
|
951
|
+
if lead is None:
|
|
952
|
+
warnings.append(
|
|
953
|
+
"no agent maps to a toolgraph identity "
|
|
954
|
+
"(configure [integration.toolgraph.agent_map]); preflight skipped"
|
|
955
|
+
)
|
|
956
|
+
return PreflightOutcome(
|
|
957
|
+
block=_error_block(cfg.profile, "unresolved_identity"),
|
|
958
|
+
warnings=warnings,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
request = _build_request(cfg, run_id=run_id, agent=lead)
|
|
962
|
+
|
|
963
|
+
# The subprocess ALWAYS writes to a tempdir; only validated bytes are then
|
|
964
|
+
# atomically copied into the run dir (_build_outcome). So a rejected artifact
|
|
965
|
+
# (bad JSON, unknown major, non-zero exit) never lingers in the run dir, and
|
|
966
|
+
# the copied bytes are exactly what the envelope digest covers.
|
|
967
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
968
|
+
out_path = Path(tmp) / PREFLIGHT_FILE
|
|
969
|
+
argv = _request_argv(cfg, request, out_path)
|
|
970
|
+
try:
|
|
971
|
+
proc = _sync_run( # owned-group runner (see gates.py note)
|
|
972
|
+
argv,
|
|
973
|
+
capture_output=True,
|
|
974
|
+
text=True,
|
|
975
|
+
# The binary's stderr becomes a warning snippet, nothing more.
|
|
976
|
+
# Strict decoding turned a latin-1 byte in it into a ValueError
|
|
977
|
+
# that the last-resort net reported as an internal error, which
|
|
978
|
+
# strict mode blocks on regardless of `unavailable_policy`.
|
|
979
|
+
errors="replace",
|
|
980
|
+
timeout=cfg.timeout_seconds,
|
|
981
|
+
)
|
|
982
|
+
except FileNotFoundError:
|
|
983
|
+
warnings.append(f"toolgraph binary {cfg.command[0]!r} not found in PATH")
|
|
984
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
985
|
+
except subprocess.TimeoutExpired:
|
|
986
|
+
warnings.append(f"preflight timed out after {cfg.timeout_seconds}s")
|
|
987
|
+
return PreflightOutcome(_error_block(cfg.profile, "timeout"), warnings)
|
|
988
|
+
except OSError as exc:
|
|
989
|
+
warnings.append(f"preflight could not launch {cfg.command[0]!r}: {exc}")
|
|
990
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
991
|
+
|
|
992
|
+
if proc.returncode != 0:
|
|
993
|
+
snippet = (proc.stderr or "").strip()[:200]
|
|
994
|
+
warnings.append(f"preflight exited {proc.returncode}: {snippet}")
|
|
995
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
996
|
+
|
|
997
|
+
try:
|
|
998
|
+
raw = out_path.read_bytes()
|
|
999
|
+
doc = json.loads(raw)
|
|
1000
|
+
except (OSError, ValueError) as exc:
|
|
1001
|
+
warnings.append(f"preflight produced unreadable output: {exc}")
|
|
1002
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
1003
|
+
|
|
1004
|
+
if not isinstance(doc, dict) or doc.get("schema_version") != SCHEMA_VERSION:
|
|
1005
|
+
warnings.append(
|
|
1006
|
+
f"preflight artifact schema_version {doc.get('schema_version') if isinstance(doc, dict) else '?'} "
|
|
1007
|
+
f"!= {SCHEMA_VERSION}; rejected"
|
|
1008
|
+
)
|
|
1009
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
1010
|
+
|
|
1011
|
+
# A right-major but malformed v1 artifact (missing/typed-wrong
|
|
1012
|
+
# graph_generation, non-list rejected, unknown decision) must NOT reach
|
|
1013
|
+
# the envelope — an "ok" block with graph_generation=None would violate
|
|
1014
|
+
# run-envelope.schema.json. Reject it as an advisory error before copying.
|
|
1015
|
+
reason = _malformed_reason(doc)
|
|
1016
|
+
if reason is not None:
|
|
1017
|
+
warnings.append(f"preflight artifact is malformed ({reason}); rejected")
|
|
1018
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
1019
|
+
|
|
1020
|
+
return _build_outcome(cfg, doc, raw, out_dir, warnings)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
_KNOWN_DECISIONS = frozenset({"advisory_allow", "advisory_warn", "unresolved_identity"})
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _malformed_reason(doc: dict) -> str | None:
|
|
1027
|
+
"""Validate the v1 fields the envelope block is built from; a reason string
|
|
1028
|
+
if the artifact can't produce a schema-valid block, else ``None``.
|
|
1029
|
+
|
|
1030
|
+
Not full jsonschema (the toolgraph schema isn't a shipped runtime asset) —
|
|
1031
|
+
just the fields consumed: ``graph_generation`` (int ≥0, required by the
|
|
1032
|
+
envelope's ``ok`` block), ``rejected`` (a list — ``warning_count`` counts
|
|
1033
|
+
it), and ``decision`` (a known value — an unknown one must not silently
|
|
1034
|
+
map to ``ok``)."""
|
|
1035
|
+
gen = doc.get("graph_generation")
|
|
1036
|
+
if not isinstance(gen, int) or isinstance(gen, bool) or gen < 0:
|
|
1037
|
+
return "graph_generation is not a non-negative integer"
|
|
1038
|
+
if not isinstance(doc.get("rejected", []), list):
|
|
1039
|
+
return "rejected is not a list"
|
|
1040
|
+
if doc.get("decision") not in _KNOWN_DECISIONS:
|
|
1041
|
+
return f"unknown decision {doc.get('decision')!r}"
|
|
1042
|
+
return None
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _build_outcome(
|
|
1046
|
+
cfg: ToolgraphConfig,
|
|
1047
|
+
doc: dict,
|
|
1048
|
+
raw: bytes,
|
|
1049
|
+
out_dir: Path | None,
|
|
1050
|
+
warnings: list[str],
|
|
1051
|
+
) -> PreflightOutcome:
|
|
1052
|
+
"""Map a valid preflight artifact to the envelope block + copy the artifact."""
|
|
1053
|
+
from syncmill.report import sha256_digest
|
|
1054
|
+
|
|
1055
|
+
decision = doc.get("decision")
|
|
1056
|
+
rejected = doc.get("rejected") or []
|
|
1057
|
+
warning_count = len(rejected) if isinstance(rejected, list) else 0
|
|
1058
|
+
|
|
1059
|
+
if decision == "unresolved_identity":
|
|
1060
|
+
status = "unresolved_identity"
|
|
1061
|
+
warnings.append(
|
|
1062
|
+
f"preflight could not resolve agent identity {doc.get('agent')!r} "
|
|
1063
|
+
"in the toolgraph"
|
|
1064
|
+
)
|
|
1065
|
+
else: # advisory_allow / advisory_warn (validated by _malformed_reason) → ok
|
|
1066
|
+
status = "ok"
|
|
1067
|
+
if warning_count:
|
|
1068
|
+
warnings.append(
|
|
1069
|
+
f"preflight: {warning_count} candidate tool(s) rejected by policy"
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
block: dict = {
|
|
1073
|
+
"status": status,
|
|
1074
|
+
"mode": "advisory",
|
|
1075
|
+
# The REQUESTED profile — guaranteed a valid enum value (the config
|
|
1076
|
+
# validator), and it's exactly what this artifact evaluated.
|
|
1077
|
+
"profile": cfg.profile,
|
|
1078
|
+
"graph_generation": doc["graph_generation"], # validated int by now
|
|
1079
|
+
"warning_count": warning_count,
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
# Copy the exact bytes into the run dir (report enabled) so the envelope's
|
|
1083
|
+
# digest verifies the artifact the consumer reads.
|
|
1084
|
+
if out_dir is not None:
|
|
1085
|
+
try:
|
|
1086
|
+
dest = _atomic_copy(raw, out_dir)
|
|
1087
|
+
block["artifact"] = PREFLIGHT_FILE
|
|
1088
|
+
block["digest"] = sha256_digest(raw)
|
|
1089
|
+
return PreflightOutcome(block, warnings, artifact_path=dest)
|
|
1090
|
+
except (OSError, ValueError) as exc:
|
|
1091
|
+
warnings.append(f"preflight artifact could not be persisted: {exc}")
|
|
1092
|
+
return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
|
|
1093
|
+
return PreflightOutcome(block, warnings)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _atomic_copy(raw: bytes, out_dir: Path) -> Path:
|
|
1097
|
+
"""Write ``raw`` to ``{out_dir}/preflight.json`` atomically (the report
|
|
1098
|
+
``write_envelope`` mkstemp+``os.replace`` recipe)."""
|
|
1099
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1100
|
+
dest = out_dir / PREFLIGHT_FILE
|
|
1101
|
+
fd, tmp = tempfile.mkstemp(dir=str(out_dir), suffix=".tmp")
|
|
1102
|
+
try:
|
|
1103
|
+
with os.fdopen(fd, "wb") as f:
|
|
1104
|
+
f.write(raw)
|
|
1105
|
+
os.replace(tmp, dest)
|
|
1106
|
+
except BaseException:
|
|
1107
|
+
try:
|
|
1108
|
+
os.unlink(tmp)
|
|
1109
|
+
except OSError:
|
|
1110
|
+
pass
|
|
1111
|
+
raise
|
|
1112
|
+
return dest
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _atomic_write(raw: bytes, dest: Path) -> Path:
|
|
1116
|
+
"""Atomically write a strict aggregate or one validated producer artifact."""
|
|
1117
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1118
|
+
fd, tmp = tempfile.mkstemp(dir=str(dest.parent), suffix=".tmp")
|
|
1119
|
+
try:
|
|
1120
|
+
with os.fdopen(fd, "wb") as handle:
|
|
1121
|
+
handle.write(raw)
|
|
1122
|
+
os.replace(tmp, dest)
|
|
1123
|
+
except BaseException:
|
|
1124
|
+
try:
|
|
1125
|
+
os.unlink(tmp)
|
|
1126
|
+
except OSError:
|
|
1127
|
+
pass
|
|
1128
|
+
raise
|
|
1129
|
+
return dest
|