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/cli.py
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
"""toolgraph command line — grows one command per phase."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from importlib.resources import as_file, files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from typing import NoReturn
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from toolgraph import __version__
|
|
18
|
+
from toolgraph import config
|
|
19
|
+
from toolgraph.artifacts import atomic_write_private, canonical_json_bytes
|
|
20
|
+
from toolgraph.control_plan import (
|
|
21
|
+
MAX_PLAN_BYTES,
|
|
22
|
+
ControlPlanError,
|
|
23
|
+
build_control_preflight,
|
|
24
|
+
)
|
|
25
|
+
from toolgraph.crawler.crawl import (
|
|
26
|
+
DEFAULT_TIMEOUT,
|
|
27
|
+
crawl_all,
|
|
28
|
+
duplicate_identities,
|
|
29
|
+
duplicate_name_overrides,
|
|
30
|
+
fleet_keep_names,
|
|
31
|
+
load_servers_config,
|
|
32
|
+
spec_label,
|
|
33
|
+
)
|
|
34
|
+
from toolgraph.graph import driver, loader, queries, schema, selector
|
|
35
|
+
from toolgraph.manifest.ingest import governance_counts, ingest_governance
|
|
36
|
+
from toolgraph.manifest.parser import load_governance
|
|
37
|
+
from toolgraph.preflight import build_preflight
|
|
38
|
+
from toolgraph.policy_bundle import PolicyBundleError, build_policy_bundle
|
|
39
|
+
from toolgraph.policy_review import PolicyReviewPlanError, build_policy_review_plan
|
|
40
|
+
from toolgraph.review_candidates import (
|
|
41
|
+
ReviewCandidateError,
|
|
42
|
+
annotate_candidate,
|
|
43
|
+
list_candidates,
|
|
44
|
+
normalize_note,
|
|
45
|
+
normalize_reviewer,
|
|
46
|
+
)
|
|
47
|
+
from toolgraph.redaction import redact_text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuditReportFormat(str, Enum):
|
|
51
|
+
json = "json"
|
|
52
|
+
markdown = "markdown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReviewDisposition(str, Enum):
|
|
56
|
+
open = "open"
|
|
57
|
+
accepted = "accepted"
|
|
58
|
+
dismissed = "dismissed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
app = typer.Typer(
|
|
62
|
+
add_completion=False,
|
|
63
|
+
help="Graph-native MCP/tool registry for advisory governance analysis.",
|
|
64
|
+
)
|
|
65
|
+
review_candidates_app = typer.Typer(
|
|
66
|
+
add_completion=False,
|
|
67
|
+
help="List and annotate Tracegraph governance review candidates.",
|
|
68
|
+
)
|
|
69
|
+
policy_app = typer.Typer(
|
|
70
|
+
add_completion=False,
|
|
71
|
+
help="Compile and review portable policy artifacts.",
|
|
72
|
+
)
|
|
73
|
+
example_app = typer.Typer(
|
|
74
|
+
add_completion=False,
|
|
75
|
+
help="Create a self-contained local quickstart.",
|
|
76
|
+
)
|
|
77
|
+
app.add_typer(review_candidates_app, name="review-candidates")
|
|
78
|
+
app.add_typer(policy_app, name="policy")
|
|
79
|
+
app.add_typer(example_app, name="example")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _reports_backend_outage(fn):
|
|
83
|
+
"""Convert a typed backend outage into the CLI's single-line ERROR contract.
|
|
84
|
+
|
|
85
|
+
Without this, an unreachable backend surfaces as a raw traceback whose
|
|
86
|
+
driver message can carry the connection URI. The MCP surface already
|
|
87
|
+
types and redacts this failure (server/app.py); the CLI must match.
|
|
88
|
+
Applied below ``@app.command`` so Typer registers the wrapped function
|
|
89
|
+
(``functools.wraps`` preserves the signature Typer introspects).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
@functools.wraps(fn)
|
|
93
|
+
def wrapper(*args, **kwargs):
|
|
94
|
+
try:
|
|
95
|
+
return fn(*args, **kwargs)
|
|
96
|
+
except config.RuntimeConfigurationError as exc:
|
|
97
|
+
typer.echo(f"ERROR: {redact_text(exc)}", err=True)
|
|
98
|
+
raise typer.Exit(code=1) from None
|
|
99
|
+
except driver.BackendUnavailableError as exc:
|
|
100
|
+
typer.echo(f"ERROR: backend unavailable: {redact_text(exc)}", err=True)
|
|
101
|
+
raise typer.Exit(code=1) from None
|
|
102
|
+
|
|
103
|
+
return wrapper
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _version_callback(value: bool) -> None:
|
|
107
|
+
if value:
|
|
108
|
+
typer.echo(f"toolgraph {__version__}")
|
|
109
|
+
raise typer.Exit()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.callback()
|
|
113
|
+
def main(
|
|
114
|
+
config_path: Path | None = typer.Option(
|
|
115
|
+
None,
|
|
116
|
+
"--config",
|
|
117
|
+
help="Runtime config path (overrides TOOLGRAPH_CONFIG and cwd .env).",
|
|
118
|
+
),
|
|
119
|
+
version: bool = typer.Option(
|
|
120
|
+
False,
|
|
121
|
+
"--version",
|
|
122
|
+
callback=_version_callback,
|
|
123
|
+
is_eager=True,
|
|
124
|
+
help="Show the installed version and exit.",
|
|
125
|
+
),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Configure Toolgraph before running a command."""
|
|
128
|
+
del version
|
|
129
|
+
if config_path is not None:
|
|
130
|
+
try:
|
|
131
|
+
config.configure(config_path)
|
|
132
|
+
except RuntimeError as exc:
|
|
133
|
+
raise typer.BadParameter(str(exc), param_hint="--config") from None
|
|
134
|
+
driver.close_driver()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@example_app.command("init")
|
|
138
|
+
def example_init(
|
|
139
|
+
destination: Path = typer.Argument(
|
|
140
|
+
Path("toolgraph-quickstart"), help="New directory for quickstart files."
|
|
141
|
+
),
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Create an install-only quickstart without cloning the repository."""
|
|
144
|
+
destination = destination.expanduser().resolve()
|
|
145
|
+
if destination.exists():
|
|
146
|
+
raise typer.BadParameter(
|
|
147
|
+
f"destination already exists: {destination}", param_hint="DESTINATION"
|
|
148
|
+
)
|
|
149
|
+
source = files("toolgraph").joinpath("quickstart")
|
|
150
|
+
# Stage next to the destination and rename once complete: a failure
|
|
151
|
+
# mid-copy must not leave a half-populated quickstart that blocks the
|
|
152
|
+
# "destination already exists" guard on the next run.
|
|
153
|
+
staging = destination.parent / f".{destination.name}.tmp-{os.getpid()}"
|
|
154
|
+
try:
|
|
155
|
+
with as_file(source) as source_path:
|
|
156
|
+
shutil.copytree(source_path, staging)
|
|
157
|
+
servers_path = staging / "servers.yaml"
|
|
158
|
+
servers_path.write_text(
|
|
159
|
+
servers_path.read_text(encoding="utf-8").replace(
|
|
160
|
+
"__TOOLGRAPH_PYTHON__", json.dumps(sys.executable)
|
|
161
|
+
),
|
|
162
|
+
encoding="utf-8",
|
|
163
|
+
)
|
|
164
|
+
os.rename(staging, destination)
|
|
165
|
+
except BaseException:
|
|
166
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
167
|
+
raise
|
|
168
|
+
typer.echo(f"Created Toolgraph quickstart at {destination}")
|
|
169
|
+
typer.echo(f"Next: cd {destination} && toolgraph init")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command()
|
|
173
|
+
@_reports_backend_outage
|
|
174
|
+
def check() -> None:
|
|
175
|
+
"""Verify the configured graph backend."""
|
|
176
|
+
driver.verify_connectivity()
|
|
177
|
+
if driver.backend_name() == "ladybug":
|
|
178
|
+
typer.echo(f"OK: backend=ladybug path={config.settings.db_path}")
|
|
179
|
+
else:
|
|
180
|
+
typer.echo(f"OK: backend=neo4j uri={config.settings.neo4j_uri}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("init")
|
|
184
|
+
@_reports_backend_outage
|
|
185
|
+
def init_backend(
|
|
186
|
+
backend: str = typer.Option(
|
|
187
|
+
"ladybug", "--backend", help="Local backend: ladybug (default) or neo4j."
|
|
188
|
+
),
|
|
189
|
+
state_dir: Path = typer.Option(
|
|
190
|
+
Path(".toolgraph"), "--state-dir", help="Local config/database directory."
|
|
191
|
+
),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Initialize a versioned local backend configuration."""
|
|
194
|
+
normalized = backend.lower().strip()
|
|
195
|
+
if normalized not in {"ladybug", "neo4j"}:
|
|
196
|
+
raise typer.BadParameter("expected ladybug or neo4j", param_hint="--backend")
|
|
197
|
+
state_dir = state_dir.expanduser().resolve()
|
|
198
|
+
runtime_path = state_dir / "config.json"
|
|
199
|
+
db_path = state_dir / "toolgraph.lbug"
|
|
200
|
+
previous = config.settings
|
|
201
|
+
db_existed = db_path.exists()
|
|
202
|
+
config.settings = config.Settings(backend=normalized, db_path=db_path)
|
|
203
|
+
driver.close_driver()
|
|
204
|
+
try:
|
|
205
|
+
schema.init_schema()
|
|
206
|
+
payload = canonical_json_bytes(
|
|
207
|
+
{
|
|
208
|
+
"schema_version": 1,
|
|
209
|
+
"backend": normalized,
|
|
210
|
+
"db_path": str(db_path),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
atomic_write_private(runtime_path, payload)
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
config.settings = previous
|
|
216
|
+
driver.close_driver()
|
|
217
|
+
# Clean up only a database this invocation plausibly created.
|
|
218
|
+
# BACKEND_LOCKED means another process owns it — it may have created
|
|
219
|
+
# the file after our db_existed check, and deleting here would
|
|
220
|
+
# destroy the winner's database.
|
|
221
|
+
if not db_existed and not isinstance(exc, driver.BackendLockedError):
|
|
222
|
+
for leftover in (db_path, Path(f"{db_path}.wal")):
|
|
223
|
+
if leftover.is_dir():
|
|
224
|
+
shutil.rmtree(leftover, ignore_errors=True)
|
|
225
|
+
else:
|
|
226
|
+
leftover.unlink(missing_ok=True)
|
|
227
|
+
raise
|
|
228
|
+
typer.echo(f"Initialized backend={normalized} config={runtime_path}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command("init-schema")
|
|
232
|
+
@_reports_backend_outage
|
|
233
|
+
def init_schema() -> None:
|
|
234
|
+
"""Create uniqueness constraints (idempotent)."""
|
|
235
|
+
schema.init_schema()
|
|
236
|
+
names = [c.get("name") for c in schema.list_constraints()]
|
|
237
|
+
typer.echo("Constraints present: " + ", ".join(sorted(n for n in names if n)))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
@_reports_backend_outage
|
|
242
|
+
def crawl(
|
|
243
|
+
servers: Path = typer.Option(None, help="Path to servers.yaml (defaults to config)."),
|
|
244
|
+
timeout: float = typer.Option(DEFAULT_TIMEOUT, help="Per-server crawl timeout (seconds)."),
|
|
245
|
+
prune: bool = typer.Option(
|
|
246
|
+
True, "--prune/--no-prune",
|
|
247
|
+
help="Fleet reconciliation (ADR-0007): after loading, retire crawled "
|
|
248
|
+
"subgraphs of servers no longer in servers.yaml. Pass --no-prune "
|
|
249
|
+
"when crawling a subset file that does not declare the whole fleet.",
|
|
250
|
+
),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Crawl every server in servers.yaml and load its tools/resources into the graph.
|
|
253
|
+
|
|
254
|
+
servers.yaml declares the fleet: by default a successful pass also retires
|
|
255
|
+
servers (and their crawl-owned subgraphs) that are no longer listed, so
|
|
256
|
+
stale EXPOSES edges cannot defeat the drift checks. Failed servers are
|
|
257
|
+
protected by their ``name`` override; an unnamed failure skips the prune.
|
|
258
|
+
"""
|
|
259
|
+
path = servers or config.settings.servers_config
|
|
260
|
+
try:
|
|
261
|
+
specs = load_servers_config(path)
|
|
262
|
+
except Exception as exc: # noqa: BLE001 - config errors share one CLI contract
|
|
263
|
+
typer.echo(f"Invalid servers config at {path}: {redact_text(exc)}", err=True)
|
|
264
|
+
raise typer.Exit(code=1) from None
|
|
265
|
+
dupes = duplicate_name_overrides(specs)
|
|
266
|
+
if dupes:
|
|
267
|
+
# Two specs claiming one name would MERGE into one MCPServer node and
|
|
268
|
+
# last-load-wins reconcile each other's tools away — reject before
|
|
269
|
+
# connecting to anything.
|
|
270
|
+
typer.echo(
|
|
271
|
+
f" [error] duplicate name override(s) in {path}: {', '.join(sorted(dupes))}",
|
|
272
|
+
err=True,
|
|
273
|
+
)
|
|
274
|
+
raise typer.Exit(code=1)
|
|
275
|
+
typer.echo(f"Crawling {len(specs)} server(s) from {path} ...")
|
|
276
|
+
|
|
277
|
+
results, failures = crawl_all(specs, timeout=timeout)
|
|
278
|
+
dupes = duplicate_identities(results, failures)
|
|
279
|
+
if dupes:
|
|
280
|
+
typer.echo(
|
|
281
|
+
" [error] multiple servers claim the same graph identity: "
|
|
282
|
+
f"{', '.join(sorted(dupes))} — add distinct `name:` overrides in "
|
|
283
|
+
f"{path}; nothing was loaded",
|
|
284
|
+
err=True,
|
|
285
|
+
)
|
|
286
|
+
raise typer.Exit(code=1)
|
|
287
|
+
schema.init_schema()
|
|
288
|
+
for result in results:
|
|
289
|
+
warnings = loader.load_crawl_result(result)
|
|
290
|
+
typer.echo(
|
|
291
|
+
f" [ok] {result.server_name} v{result.server_version} "
|
|
292
|
+
f"({len(result.tools)} tools, {len(result.resources)} resources)"
|
|
293
|
+
)
|
|
294
|
+
for w in warnings:
|
|
295
|
+
typer.echo(f" [warn] {w}")
|
|
296
|
+
for spec, error in failures:
|
|
297
|
+
typer.echo(f" [error] {spec_label(spec)}: {error}")
|
|
298
|
+
|
|
299
|
+
if prune:
|
|
300
|
+
keep, blockers = fleet_keep_names(specs, results, failures)
|
|
301
|
+
if blockers:
|
|
302
|
+
typer.echo(
|
|
303
|
+
" [warn] fleet reconciliation skipped: failed server(s) "
|
|
304
|
+
f"{', '.join(sorted(blockers))} have no `name:` override in "
|
|
305
|
+
f"{path}, so a crawl failure cannot be told apart from a "
|
|
306
|
+
"removed server — add `name:` to keep reconciliation running "
|
|
307
|
+
"through failures"
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
retired, warnings = loader.retire_unlisted_servers(keep)
|
|
311
|
+
for name in retired:
|
|
312
|
+
typer.echo(f" retired {name!r}: no longer in {path}")
|
|
313
|
+
for w in warnings:
|
|
314
|
+
typer.echo(f" [warn] {w}")
|
|
315
|
+
|
|
316
|
+
counts = loader.graph_counts()
|
|
317
|
+
typer.echo(f"Graph now: {counts}")
|
|
318
|
+
if failures:
|
|
319
|
+
raise typer.Exit(code=1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command("ingest-manifest")
|
|
323
|
+
@_reports_backend_outage
|
|
324
|
+
def ingest_manifest(
|
|
325
|
+
governance: Path = typer.Option(None, help="Path to governance.yaml (defaults to config)."),
|
|
326
|
+
strict_drift: bool = typer.Option(
|
|
327
|
+
False, "--strict-drift",
|
|
328
|
+
help="Treat drifted tool refs (no live EXPOSES edge) as errors that "
|
|
329
|
+
"reject the manifest. Default: non-fatal notice.",
|
|
330
|
+
),
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Ingest authored governance (agents, policies, ACL, data access, GOVERNED_BY)."""
|
|
333
|
+
path = governance or config.settings.governance_config
|
|
334
|
+
try:
|
|
335
|
+
gov = load_governance(path)
|
|
336
|
+
except Exception as exc: # noqa: BLE001 - config errors share one CLI contract
|
|
337
|
+
typer.echo(f"Invalid governance config at {path}: {redact_text(exc)}", err=True)
|
|
338
|
+
raise typer.Exit(code=1) from None
|
|
339
|
+
schema.init_schema()
|
|
340
|
+
report = ingest_governance(gov, strict_drift=strict_drift)
|
|
341
|
+
if report.warnings:
|
|
342
|
+
# Ingest is atomic: any warning rejects the whole manifest. Reflect
|
|
343
|
+
# that in the headline so an operator with a typo doesn't read
|
|
344
|
+
# "Ingested" as success.
|
|
345
|
+
typer.echo(f"REJECTED — manifest at {path} not applied (graph unchanged)")
|
|
346
|
+
for w in report.warnings:
|
|
347
|
+
typer.echo(f" [warn] {w}")
|
|
348
|
+
for n in report.notices:
|
|
349
|
+
typer.echo(f" [info] {n}")
|
|
350
|
+
raise typer.Exit(code=1)
|
|
351
|
+
for n in report.notices:
|
|
352
|
+
typer.echo(f" [info] {n}")
|
|
353
|
+
typer.echo(f"Ingested governance from {path}")
|
|
354
|
+
typer.echo(f"Governance now: {governance_counts()}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.command()
|
|
358
|
+
@_reports_backend_outage
|
|
359
|
+
def reset(yes: bool = typer.Option(False, "--yes", help="Confirm deletion of ALL graph data.")) -> None:
|
|
360
|
+
"""Delete every node and relationship (keeps constraints). Destructive.
|
|
361
|
+
|
|
362
|
+
Not atomic: deletion runs in batches, so run it inside a maintenance
|
|
363
|
+
window on a shared backend — a concurrent writer can interleave, and an
|
|
364
|
+
outage mid-way leaves a partially erased graph (re-run to finish).
|
|
365
|
+
"""
|
|
366
|
+
if not yes:
|
|
367
|
+
typer.echo("Refusing to wipe the graph without --yes")
|
|
368
|
+
raise typer.Exit(code=1)
|
|
369
|
+
# Enforce the schema-version gate BEFORE destroying GraphMeta: an older
|
|
370
|
+
# binary must not erase a newer database and then re-stamp it as its own.
|
|
371
|
+
schema.init_schema()
|
|
372
|
+
with driver.session() as s:
|
|
373
|
+
# Batched: one unbounded DETACH DELETE builds a transaction holding
|
|
374
|
+
# every node and can OOM the server on a large graph.
|
|
375
|
+
while True:
|
|
376
|
+
record = s.run(
|
|
377
|
+
"MATCH (n) WITH n LIMIT 10000 DETACH DELETE n RETURN count(n) AS deleted"
|
|
378
|
+
).single()
|
|
379
|
+
if not record or not record["deleted"]:
|
|
380
|
+
break
|
|
381
|
+
# Recreate GraphMeta at generation 0 with a fresh instance id. A reset may
|
|
382
|
+
# reuse generation numbers but must never reuse an external graph-state key.
|
|
383
|
+
schema.init_schema()
|
|
384
|
+
typer.echo("Graph cleared.")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.command()
|
|
388
|
+
def serve(
|
|
389
|
+
http: bool = typer.Option(False, "--http", help="Serve over streamable-http instead of stdio."),
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Run toolgraph as an MCP server exposing the governance queries as tools."""
|
|
392
|
+
from toolgraph.server.app import mcp
|
|
393
|
+
|
|
394
|
+
mcp.run(transport="streamable-http") if http else mcp.run()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command("check-access")
|
|
398
|
+
@_reports_backend_outage
|
|
399
|
+
def check_access(
|
|
400
|
+
agent: str,
|
|
401
|
+
tool: str,
|
|
402
|
+
fail_on_deny: bool = typer.Option(
|
|
403
|
+
False, "--fail-on-deny",
|
|
404
|
+
help="CI gate (ADR-0003): exit 1 on a DENY that isn't fully covered "
|
|
405
|
+
"by operator exceptions, 2 on a not-found/ambiguous verdict. "
|
|
406
|
+
"Default: always exit 0.",
|
|
407
|
+
),
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Is AGENT allowed to call TOOL, and does the call cross a DENY policy?
|
|
410
|
+
|
|
411
|
+
Exit codes are advisory (always 0) unless ``--fail-on-deny`` is passed.
|
|
412
|
+
"""
|
|
413
|
+
result = queries.check_access(agent, tool)
|
|
414
|
+
typer.echo(json.dumps(result, indent=2))
|
|
415
|
+
if not fail_on_deny:
|
|
416
|
+
return
|
|
417
|
+
if result["verdict"] in ("AGENT_NOT_FOUND", "TOOL_NOT_FOUND", "AMBIGUOUS_TOOL"):
|
|
418
|
+
# A typo'd name must not pass a CI gate as a clean run.
|
|
419
|
+
raise typer.Exit(code=2)
|
|
420
|
+
if result["verdict"] == "DENY" and not result["all_authorized"]:
|
|
421
|
+
raise typer.Exit(code=1)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@app.command("unsafe-tools")
|
|
425
|
+
@_reports_backend_outage
|
|
426
|
+
def unsafe_tools(
|
|
427
|
+
agent: str,
|
|
428
|
+
all: bool = typer.Option(
|
|
429
|
+
False, "--all",
|
|
430
|
+
help="Include authorized_but_governed rows (operator-declared exceptions).",
|
|
431
|
+
),
|
|
432
|
+
fail_on_violation: bool = typer.Option(
|
|
433
|
+
False, "--fail-on-violation",
|
|
434
|
+
help="CI gate (ADR-0003): exit 1 when any violation row remains in "
|
|
435
|
+
"the output. Default: exit 0 regardless of findings.",
|
|
436
|
+
),
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Tools AGENT can call that touch a DENY-governed resource.
|
|
439
|
+
|
|
440
|
+
Defaults to ``classification='violation'`` only — operator-declared
|
|
441
|
+
exceptions (``authorized_but_governed``) are filtered out so reviewers
|
|
442
|
+
don't drown in by-design grants. Pass ``--all`` to see both.
|
|
443
|
+
|
|
444
|
+
A typo'd agent prints AGENT_NOT_FOUND on stderr and exits 1 — not
|
|
445
|
+
silently empty. Stdout carries only the JSON (ADR-0003).
|
|
446
|
+
"""
|
|
447
|
+
if not queries.agent_exists(agent):
|
|
448
|
+
typer.echo(f"AGENT_NOT_FOUND: no agent named {agent!r} in the graph", err=True)
|
|
449
|
+
raise typer.Exit(code=1)
|
|
450
|
+
rows = queries.unsafe_callable_tools(agent)
|
|
451
|
+
if not all:
|
|
452
|
+
rows = [r for r in rows if r.get("classification") == "violation"]
|
|
453
|
+
typer.echo(json.dumps(rows, indent=2))
|
|
454
|
+
if fail_on_violation and any(r.get("classification") == "violation" for r in rows):
|
|
455
|
+
raise typer.Exit(code=1)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@app.command("blast-radius")
|
|
459
|
+
@_reports_backend_outage
|
|
460
|
+
def blast_radius(node: str) -> None:
|
|
461
|
+
"""Agents/tools affected if NODE (a resource URI or policy id) changes."""
|
|
462
|
+
typer.echo(json.dumps(queries.blast_radius(node), indent=2))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@app.command("unmapped-tools")
|
|
466
|
+
@_reports_backend_outage
|
|
467
|
+
def unmapped_tools(
|
|
468
|
+
all: bool = typer.Option(False, "--all", help="Include tools with no CAN_CALL grant."),
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Tools whose data-flow effects (READS/WRITES) are not yet authored."""
|
|
471
|
+
typer.echo(json.dumps(queries.unmapped_tools(only_granted=not all), indent=2))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@app.command("orphan-policies")
|
|
475
|
+
@_reports_backend_outage
|
|
476
|
+
def orphan_policies() -> None:
|
|
477
|
+
"""Policies no agent currently reaches — likely obsolete or over-narrow."""
|
|
478
|
+
typer.echo(json.dumps(queries.orphan_policies(), indent=2))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@app.command("unbacked-edges")
|
|
482
|
+
@_reports_backend_outage
|
|
483
|
+
def unbacked_edges(
|
|
484
|
+
include_grants: bool = typer.Option(
|
|
485
|
+
False, "--include-grants",
|
|
486
|
+
help="Include CAN_CALL grants (intent declarations) — usually noise.",
|
|
487
|
+
),
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Authored edges with no evidence pointer — unsupported claims.
|
|
490
|
+
|
|
491
|
+
Default scope is READS/WRITES/GOVERNED_BY (factual / load-bearing claims).
|
|
492
|
+
CAN_CALL grants are excluded by default — a grant is intent, not a
|
|
493
|
+
factual claim, so requiring evidence on every grant pollutes the audit.
|
|
494
|
+
"""
|
|
495
|
+
typer.echo(
|
|
496
|
+
json.dumps(queries.unbacked_edges(include_grants=include_grants), indent=2)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app.command("drift")
|
|
501
|
+
@_reports_backend_outage
|
|
502
|
+
def drift() -> None:
|
|
503
|
+
"""Tools with authored governance but no live EXPOSES edge (re-ingest needed)."""
|
|
504
|
+
typer.echo(json.dumps(queries.drifted_tools(), indent=2))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@app.command("destructive-unsafeguarded")
|
|
508
|
+
@_reports_backend_outage
|
|
509
|
+
def destructive_unsafeguarded() -> None:
|
|
510
|
+
"""Destructive-hinted tools with no GOVERNED_BY path (direct or via any resource).
|
|
511
|
+
|
|
512
|
+
The hint is the server's self-claim (crawled/medium, ADR-0006) — these rows
|
|
513
|
+
are an authoring priority queue, not violations.
|
|
514
|
+
"""
|
|
515
|
+
typer.echo(json.dumps(queries.destructive_unsafeguarded(), indent=2))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@app.command("annotation-contradictions")
|
|
519
|
+
@_reports_backend_outage
|
|
520
|
+
def annotation_contradictions() -> None:
|
|
521
|
+
"""Authored WRITES on tools hinting readOnlyHint: true — one side is wrong.
|
|
522
|
+
|
|
523
|
+
Both sides cite their provenance; the operator picks the stronger evidence.
|
|
524
|
+
"""
|
|
525
|
+
typer.echo(json.dumps(queries.annotation_contradictions(), indent=2))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _audit_report_markdown(report: dict) -> str:
|
|
529
|
+
lines = [
|
|
530
|
+
"# toolgraph audit report",
|
|
531
|
+
"",
|
|
532
|
+
f"Status: **{report['status'].upper()}**",
|
|
533
|
+
"",
|
|
534
|
+
"| Finding | Count | Blocking |",
|
|
535
|
+
"| --- | ---: | --- |",
|
|
536
|
+
]
|
|
537
|
+
blocking = set(report["summary"]["blocking_findings"])
|
|
538
|
+
for name, count in report["summary"]["counts"].items():
|
|
539
|
+
lines.append(f"| `{name}` | {count} | {'yes' if name in blocking else 'no'} |")
|
|
540
|
+
lines.append("")
|
|
541
|
+
for name, rows in report["findings"].items():
|
|
542
|
+
if not rows:
|
|
543
|
+
continue
|
|
544
|
+
lines.extend(
|
|
545
|
+
[
|
|
546
|
+
f"## {name.replace('_', ' ').title()}",
|
|
547
|
+
"",
|
|
548
|
+
"```json",
|
|
549
|
+
json.dumps(rows, indent=2),
|
|
550
|
+
"```",
|
|
551
|
+
"",
|
|
552
|
+
]
|
|
553
|
+
)
|
|
554
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@app.command("audit-report")
|
|
558
|
+
@_reports_backend_outage
|
|
559
|
+
def audit_report(
|
|
560
|
+
format: AuditReportFormat = typer.Option(
|
|
561
|
+
AuditReportFormat.json,
|
|
562
|
+
"--format",
|
|
563
|
+
help="Output format: json (automation) or markdown (human review).",
|
|
564
|
+
),
|
|
565
|
+
include_grants: bool = typer.Option(
|
|
566
|
+
False,
|
|
567
|
+
"--include-grants",
|
|
568
|
+
help="Include CAN_CALL grants in the unbacked-edge evidence audit.",
|
|
569
|
+
),
|
|
570
|
+
fail_on_blocking: bool = typer.Option(
|
|
571
|
+
False,
|
|
572
|
+
"--fail-on-blocking",
|
|
573
|
+
help="CI gate: exit 1 when blocking findings remain. Default: exit 0.",
|
|
574
|
+
),
|
|
575
|
+
) -> None:
|
|
576
|
+
"""Whole-graph operational audit summary."""
|
|
577
|
+
report = queries.audit_report(include_grants=include_grants)
|
|
578
|
+
if format is AuditReportFormat.markdown:
|
|
579
|
+
typer.echo(_audit_report_markdown(report), nl=False)
|
|
580
|
+
else:
|
|
581
|
+
typer.echo(json.dumps(report, indent=2))
|
|
582
|
+
if fail_on_blocking and report["status"] == "fail":
|
|
583
|
+
raise typer.Exit(code=1)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _selector_profile(profile: str) -> str:
|
|
587
|
+
if profile not in selector.PROFILES:
|
|
588
|
+
raise typer.BadParameter(
|
|
589
|
+
f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
|
|
590
|
+
)
|
|
591
|
+
return profile
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# The binding bound lives in the selector (every surface shares it); the CLI
|
|
595
|
+
# pre-checks only to produce a usage error instead of a traceback.
|
|
596
|
+
MAX_CLI_CANDIDATES = selector.MAX_CANDIDATES
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _bounded_candidates(candidates: list[str]) -> list[str]:
|
|
600
|
+
if len(candidates) > MAX_CLI_CANDIDATES:
|
|
601
|
+
raise typer.BadParameter(
|
|
602
|
+
f"{len(candidates)} candidates exceed the {MAX_CLI_CANDIDATES} limit",
|
|
603
|
+
param_hint="CANDIDATES",
|
|
604
|
+
)
|
|
605
|
+
return candidates
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _require_agent(agent: str) -> None:
|
|
609
|
+
"""Selection with an unknown agent is a context-construction error, not an
|
|
610
|
+
empty result — mirror unsafe-tools: diagnostic to stderr, exit 1."""
|
|
611
|
+
if not queries.agent_exists(agent):
|
|
612
|
+
typer.echo(f"AGENT_NOT_FOUND: no agent named {agent!r} in the graph", err=True)
|
|
613
|
+
raise typer.Exit(code=1)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@app.command("rank-features")
|
|
617
|
+
@_reports_backend_outage
|
|
618
|
+
def rank_features(agent: str, candidates: list[str]) -> None:
|
|
619
|
+
"""Batch selection features for CANDIDATES (ADR-0005), in input order.
|
|
620
|
+
|
|
621
|
+
Facts only — resolution, grant, DENY classification + paths, drift,
|
|
622
|
+
mapping, evidence coverage, annotation self-claims — plus the rule-based
|
|
623
|
+
risk_score from the published fixed table. No relevance, no learning.
|
|
624
|
+
"""
|
|
625
|
+
_bounded_candidates(candidates)
|
|
626
|
+
_require_agent(agent)
|
|
627
|
+
typer.echo(json.dumps(selector.rank_features(agent, candidates), indent=2))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@app.command("eligible-tools")
|
|
631
|
+
@_reports_backend_outage
|
|
632
|
+
def eligible_tools(
|
|
633
|
+
agent: str,
|
|
634
|
+
candidates: list[str],
|
|
635
|
+
profile: str = typer.Option(
|
|
636
|
+
selector.DEFAULT_PROFILE, "--profile",
|
|
637
|
+
help="Named rule set: strict (production default) / review / explore.",
|
|
638
|
+
),
|
|
639
|
+
) -> None:
|
|
640
|
+
"""Hard-filter CANDIDATES with reject reasons + policy-evidence paths.
|
|
641
|
+
|
|
642
|
+
A learned consumer may rerank the eligible list but may never resurrect
|
|
643
|
+
a rejected row (ADR-0005).
|
|
644
|
+
"""
|
|
645
|
+
_selector_profile(profile)
|
|
646
|
+
_bounded_candidates(candidates)
|
|
647
|
+
_require_agent(agent)
|
|
648
|
+
typer.echo(
|
|
649
|
+
json.dumps(selector.eligible_tools(agent, candidates, profile=profile), indent=2)
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.command("selection-explain")
|
|
654
|
+
@_reports_backend_outage
|
|
655
|
+
def selection_explain(
|
|
656
|
+
agent: str,
|
|
657
|
+
tool: str,
|
|
658
|
+
profile: str = typer.Option(
|
|
659
|
+
selector.DEFAULT_PROFILE, "--profile",
|
|
660
|
+
help="Named rule set: strict (production default) / review / explore.",
|
|
661
|
+
),
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Compact human-readable reasons why TOOL is eligible/rejected for AGENT."""
|
|
664
|
+
_selector_profile(profile)
|
|
665
|
+
_require_agent(agent)
|
|
666
|
+
typer.echo(
|
|
667
|
+
json.dumps(selector.selection_explain(agent, tool, profile=profile), indent=2)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@app.command("preflight")
|
|
672
|
+
@_reports_backend_outage
|
|
673
|
+
def preflight(
|
|
674
|
+
agent: str,
|
|
675
|
+
candidates: list[str],
|
|
676
|
+
profile: str = typer.Option(
|
|
677
|
+
selector.DEFAULT_PROFILE, "--profile",
|
|
678
|
+
help="Named selector profile: strict / review / explore.",
|
|
679
|
+
),
|
|
680
|
+
run_id: str = typer.Option(..., "--run-id", help="Stable ecosystem run identifier."),
|
|
681
|
+
features: bool = typer.Option(False, "--features", help="Include selector feature rows."),
|
|
682
|
+
out: Path | None = typer.Option(None, "--out", help="Write the JSON artifact to this path."),
|
|
683
|
+
) -> None:
|
|
684
|
+
"""Produce an advisory preflight artifact for a candidate tool batch."""
|
|
685
|
+
_selector_profile(profile)
|
|
686
|
+
_bounded_candidates(candidates)
|
|
687
|
+
try:
|
|
688
|
+
artifact = build_preflight(
|
|
689
|
+
agent=agent,
|
|
690
|
+
candidates=candidates,
|
|
691
|
+
profile=profile,
|
|
692
|
+
run_id=run_id,
|
|
693
|
+
include_features=features,
|
|
694
|
+
)
|
|
695
|
+
# Indented rather than canonical bytes: this artifact is read by operators
|
|
696
|
+
# as often as by consumers. The digest covers the exact bytes on disk, so a
|
|
697
|
+
# consumer can verify it with sha256sum without re-serializing.
|
|
698
|
+
payload = json.dumps(artifact, indent=2) + "\n"
|
|
699
|
+
if out is None:
|
|
700
|
+
typer.echo(payload, nl=False)
|
|
701
|
+
return
|
|
702
|
+
digest = atomic_write_private(out, payload.encode("utf-8"))
|
|
703
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
704
|
+
typer.echo(f"ERROR: {redact_text(exc)}", err=True)
|
|
705
|
+
raise typer.Exit(code=1) from exc
|
|
706
|
+
typer.echo(
|
|
707
|
+
json.dumps(
|
|
708
|
+
{
|
|
709
|
+
"kind": artifact["kind"],
|
|
710
|
+
"output": str(out),
|
|
711
|
+
# Prefixed form: SyncMill's artifact_digest contract is
|
|
712
|
+
# ^sha256:[0-9a-f]{64}$, so this value is copyable as-is.
|
|
713
|
+
# `policy compile` deliberately keeps bare hex — memtomem-stm
|
|
714
|
+
# asserts len(bundle_digest) == 64. The two formats answer to
|
|
715
|
+
# different consumers; do not unify them.
|
|
716
|
+
"artifact_digest": f"sha256:{digest}",
|
|
717
|
+
"run_id": artifact["run_id"],
|
|
718
|
+
"graph_generation": artifact["graph_generation"],
|
|
719
|
+
"agent": artifact["agent"],
|
|
720
|
+
"profile": artifact["profile"],
|
|
721
|
+
"decision": artifact["decision"],
|
|
722
|
+
"eligible": len(artifact["eligible"]),
|
|
723
|
+
"rejected": len(artifact["rejected"]),
|
|
724
|
+
},
|
|
725
|
+
indent=2,
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@app.command("control-preflight")
|
|
731
|
+
@_reports_backend_outage
|
|
732
|
+
def control_preflight(
|
|
733
|
+
plan: Path = typer.Argument(..., help="Body-free toolgraph.control-plan JSON file."),
|
|
734
|
+
profile: str = typer.Option(
|
|
735
|
+
selector.DEFAULT_PROFILE,
|
|
736
|
+
"--profile",
|
|
737
|
+
help="Named selector profile: strict / review / explore.",
|
|
738
|
+
),
|
|
739
|
+
out: Path | None = typer.Option(
|
|
740
|
+
None, "--out", help="Write the private canonical JSON artifact to this path."
|
|
741
|
+
),
|
|
742
|
+
) -> None:
|
|
743
|
+
"""Validate and evaluate one bounded orchestration control plan."""
|
|
744
|
+
_selector_profile(profile)
|
|
745
|
+
digest: str | None = None
|
|
746
|
+
try:
|
|
747
|
+
# Bounded read: MAX_PLAN_BYTES must limit memory, not just validation
|
|
748
|
+
# cost, and a stat-then-read pair would be a TOCTOU.
|
|
749
|
+
with plan.open("rb") as handle:
|
|
750
|
+
raw_plan = handle.read(MAX_PLAN_BYTES + 1)
|
|
751
|
+
if len(raw_plan) > MAX_PLAN_BYTES:
|
|
752
|
+
raise ControlPlanError(
|
|
753
|
+
f"control plan exceeds the {MAX_PLAN_BYTES}-byte input limit"
|
|
754
|
+
)
|
|
755
|
+
artifact = build_control_preflight(raw_plan, profile=profile)
|
|
756
|
+
payload = canonical_json_bytes(artifact)
|
|
757
|
+
if out is not None:
|
|
758
|
+
digest = atomic_write_private(out, payload)
|
|
759
|
+
except (ControlPlanError, RuntimeError, OSError, ValueError) as exc:
|
|
760
|
+
typer.echo(f"ERROR: {redact_text(exc)}", err=True)
|
|
761
|
+
raise typer.Exit(code=1) from exc
|
|
762
|
+
if out is None:
|
|
763
|
+
typer.echo(payload.decode(), nl=False)
|
|
764
|
+
return
|
|
765
|
+
typer.echo(
|
|
766
|
+
json.dumps(
|
|
767
|
+
{
|
|
768
|
+
"kind": artifact["kind"],
|
|
769
|
+
"output": str(out),
|
|
770
|
+
# Prefixed to match the plan_digest inside the artifact and
|
|
771
|
+
# SyncMill's ^sha256:[0-9a-f]{64}$ contract.
|
|
772
|
+
"artifact_digest": f"sha256:{digest}",
|
|
773
|
+
"plan_digest": artifact["plan_digest"],
|
|
774
|
+
"run_id": artifact["run_id"],
|
|
775
|
+
"graph_state": artifact["graph_state"],
|
|
776
|
+
"profile": artifact["profile"],
|
|
777
|
+
"decision": artifact["decision"],
|
|
778
|
+
"findings": len(artifact["findings"]),
|
|
779
|
+
"evaluations": len(artifact["evaluations"]),
|
|
780
|
+
},
|
|
781
|
+
indent=2,
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@policy_app.command("compile")
|
|
787
|
+
@_reports_backend_outage
|
|
788
|
+
def policy_compile(
|
|
789
|
+
agent: str = typer.Option(..., "--agent", help="Authored agent identity."),
|
|
790
|
+
profile: str = typer.Option(
|
|
791
|
+
selector.DEFAULT_PROFILE,
|
|
792
|
+
"--profile",
|
|
793
|
+
help="Named selector profile: strict / review / explore.",
|
|
794
|
+
),
|
|
795
|
+
output: Path = typer.Option(..., "--output", help="Private policy bundle path."),
|
|
796
|
+
) -> None:
|
|
797
|
+
"""Compile a product-neutral policy bundle for an enforcement gateway."""
|
|
798
|
+
_selector_profile(profile)
|
|
799
|
+
try:
|
|
800
|
+
artifact = build_policy_bundle(agent=agent, profile=profile)
|
|
801
|
+
payload = canonical_json_bytes(artifact)
|
|
802
|
+
digest = atomic_write_private(output, payload)
|
|
803
|
+
except (PolicyBundleError, RuntimeError, OSError, ValueError) as exc:
|
|
804
|
+
typer.echo(f"ERROR: {redact_text(exc)}", err=True)
|
|
805
|
+
raise typer.Exit(code=1) from exc
|
|
806
|
+
typer.echo(
|
|
807
|
+
json.dumps(
|
|
808
|
+
{
|
|
809
|
+
"kind": artifact["kind"],
|
|
810
|
+
"output": str(output),
|
|
811
|
+
"bundle_digest": digest,
|
|
812
|
+
"graph_state": artifact["graph_state"],
|
|
813
|
+
"agent": artifact["agent"],
|
|
814
|
+
"profile": artifact["profile"],
|
|
815
|
+
"eligible": sum(t["decision"] == "eligible" for t in artifact["tools"]),
|
|
816
|
+
"rejected": sum(t["decision"] == "rejected" for t in artifact["tools"]),
|
|
817
|
+
},
|
|
818
|
+
indent=2,
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@policy_app.command("review-plan")
|
|
824
|
+
@_reports_backend_outage
|
|
825
|
+
def policy_review_plan(
|
|
826
|
+
report: Path = typer.Argument(..., help="Tracegraph review-candidate report."),
|
|
827
|
+
agent: str = typer.Option(..., "--agent", help="Authored agent identity."),
|
|
828
|
+
profile: str = typer.Option(
|
|
829
|
+
selector.DEFAULT_PROFILE,
|
|
830
|
+
"--profile",
|
|
831
|
+
help="Named selector profile: strict / review / explore.",
|
|
832
|
+
),
|
|
833
|
+
annotations: Path | None = typer.Option(
|
|
834
|
+
None,
|
|
835
|
+
"--annotations",
|
|
836
|
+
help="Annotation sidecar (default: REPORT.toolgraph-review.json).",
|
|
837
|
+
),
|
|
838
|
+
output: Path = typer.Option(..., "--output", help="Private review-plan artifact path."),
|
|
839
|
+
) -> None:
|
|
840
|
+
"""Project accepted trace evidence into a human-required policy work plan."""
|
|
841
|
+
_selector_profile(profile)
|
|
842
|
+
try:
|
|
843
|
+
artifact = build_policy_review_plan(
|
|
844
|
+
report=report,
|
|
845
|
+
agent=agent,
|
|
846
|
+
profile=profile,
|
|
847
|
+
annotations=annotations,
|
|
848
|
+
)
|
|
849
|
+
digest = atomic_write_private(output, canonical_json_bytes(artifact))
|
|
850
|
+
except (PolicyReviewPlanError, RuntimeError, OSError, ValueError) as exc:
|
|
851
|
+
typer.echo(f"ERROR: {redact_text(exc)}", err=True)
|
|
852
|
+
raise typer.Exit(code=1) from exc
|
|
853
|
+
typer.echo(
|
|
854
|
+
json.dumps(
|
|
855
|
+
{
|
|
856
|
+
"kind": artifact["kind"],
|
|
857
|
+
"output": str(output),
|
|
858
|
+
# Prefixed like preflight/control-preflight: artifact_digest
|
|
859
|
+
# follows SyncMill's ^sha256:[0-9a-f]{64}$ contract; bare hex
|
|
860
|
+
# is reserved for bundle_digest (memtomem-stm).
|
|
861
|
+
"artifact_digest": f"sha256:{digest}",
|
|
862
|
+
"graph_state": artifact["graph_state"],
|
|
863
|
+
"agent": artifact["agent"],
|
|
864
|
+
"profile": artifact["profile"],
|
|
865
|
+
"candidates": len(artifact["candidates"]),
|
|
866
|
+
"decision_mode": artifact["decision_mode"],
|
|
867
|
+
},
|
|
868
|
+
indent=2,
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _review_candidate_failure(exc: ReviewCandidateError) -> NoReturn:
|
|
874
|
+
typer.echo(f"ERROR: {redact_text(exc)}", err=True)
|
|
875
|
+
raise typer.Exit(code=1)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@review_candidates_app.command("list")
|
|
879
|
+
def review_candidates_list(
|
|
880
|
+
report: Path = typer.Argument(..., help="Tracegraph review-candidate report."),
|
|
881
|
+
annotations: Path | None = typer.Option(
|
|
882
|
+
None,
|
|
883
|
+
"--annotations",
|
|
884
|
+
help="Annotation sidecar path (default: REPORT.toolgraph-review.json).",
|
|
885
|
+
),
|
|
886
|
+
status: ReviewDisposition | None = typer.Option(
|
|
887
|
+
None,
|
|
888
|
+
"--status",
|
|
889
|
+
help="Filter by current disposition: open / accepted / dismissed.",
|
|
890
|
+
),
|
|
891
|
+
) -> None:
|
|
892
|
+
"""List validated candidates merged with Toolgraph's local review history."""
|
|
893
|
+
try:
|
|
894
|
+
payload = list_candidates(
|
|
895
|
+
report,
|
|
896
|
+
annotations_path=annotations,
|
|
897
|
+
status=status.value if status is not None else None,
|
|
898
|
+
)
|
|
899
|
+
except ReviewCandidateError as exc:
|
|
900
|
+
_review_candidate_failure(exc)
|
|
901
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@review_candidates_app.command("annotate")
|
|
905
|
+
def review_candidates_annotate(
|
|
906
|
+
report: Path = typer.Argument(..., help="Tracegraph review-candidate report."),
|
|
907
|
+
candidate_id: str = typer.Argument(
|
|
908
|
+
..., help="Stable UUIDv5 candidate id printed by `review-candidates list`."
|
|
909
|
+
),
|
|
910
|
+
status: ReviewDisposition = typer.Option(
|
|
911
|
+
...,
|
|
912
|
+
"--status",
|
|
913
|
+
help="New disposition: open / accepted / dismissed.",
|
|
914
|
+
),
|
|
915
|
+
reviewer: str = typer.Option(..., "--reviewer", help="Human reviewer identity."),
|
|
916
|
+
note: str | None = typer.Option(
|
|
917
|
+
None,
|
|
918
|
+
"--note",
|
|
919
|
+
help="Optional local review note (single line, at most 500 characters).",
|
|
920
|
+
),
|
|
921
|
+
annotations: Path | None = typer.Option(
|
|
922
|
+
None,
|
|
923
|
+
"--annotations",
|
|
924
|
+
help="Annotation sidecar path (default: REPORT.toolgraph-review.json).",
|
|
925
|
+
),
|
|
926
|
+
) -> None:
|
|
927
|
+
"""Append a human disposition; never change graph policy or manifests."""
|
|
928
|
+
try:
|
|
929
|
+
reviewer = normalize_reviewer(reviewer)
|
|
930
|
+
except ReviewCandidateError as exc:
|
|
931
|
+
raise typer.BadParameter(str(exc), param_hint="--reviewer") from exc
|
|
932
|
+
try:
|
|
933
|
+
note = normalize_note(note)
|
|
934
|
+
except ReviewCandidateError as exc:
|
|
935
|
+
raise typer.BadParameter(str(exc), param_hint="--note") from exc
|
|
936
|
+
try:
|
|
937
|
+
payload = annotate_candidate(
|
|
938
|
+
report,
|
|
939
|
+
candidate_id,
|
|
940
|
+
disposition=status.value,
|
|
941
|
+
reviewer=reviewer,
|
|
942
|
+
note=note,
|
|
943
|
+
annotations_path=annotations,
|
|
944
|
+
)
|
|
945
|
+
except ReviewCandidateError as exc:
|
|
946
|
+
_review_candidate_failure(exc)
|
|
947
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
if __name__ == "__main__":
|
|
951
|
+
app()
|