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/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()