agentspm 0.1.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.
agentspm/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """agentspm — Agent Security Posture Management blueprint (v0.1, in development).
2
+
3
+ Posture, not enforcement: finds, documents, and maps — never enforces.
4
+ """
5
+
6
+ __version__ = "0.1.1"
agentspm/canonical.py ADDED
@@ -0,0 +1,63 @@
1
+ """Canonical serialization and deterministic hashing (FR-10).
2
+
3
+ Every hash in this repository is computed over the *canonical JSON form*:
4
+ sorted keys, no whitespace, UTF-8, no NaN. Two runs over the same graph must
5
+ produce byte-identical hashes, so nothing in this module may consult the
6
+ clock, the environment, or any random source.
7
+
8
+ Timestamp fields are volatile run metadata, not graph content: they are
9
+ stripped before hashing (``strip_timestamps``) so that re-collecting an
10
+ unchanged graph yields the same ``graph_hash`` (AK-08 / TC-16).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from typing import Any
18
+
19
+ # Volatile timestamp keys excluded from content/graph hashes (FR-10).
20
+ TIMESTAMP_KEYS = frozenset(
21
+ {
22
+ "collected_at",
23
+ "last_seen",
24
+ "entitlement_review_at",
25
+ "evaluated_at",
26
+ }
27
+ )
28
+
29
+
30
+ def canonical_json(value: Any) -> str:
31
+ """Serialize ``value`` to canonical JSON (sorted keys, compact, UTF-8)."""
32
+ return json.dumps(
33
+ value,
34
+ sort_keys=True,
35
+ separators=(",", ":"),
36
+ ensure_ascii=False,
37
+ allow_nan=False,
38
+ )
39
+
40
+
41
+ def strip_timestamps(value: Any) -> Any:
42
+ """Recursively remove volatile timestamp keys; used for hashing only."""
43
+ if isinstance(value, dict):
44
+ return {
45
+ key: strip_timestamps(item)
46
+ for key, item in value.items()
47
+ if key not in TIMESTAMP_KEYS
48
+ }
49
+ if isinstance(value, list):
50
+ return [strip_timestamps(item) for item in value]
51
+ return value
52
+
53
+
54
+ def sha256_hex(data: bytes | str) -> str:
55
+ """Hex SHA-256 of raw bytes (strings are encoded as UTF-8)."""
56
+ if isinstance(data, str):
57
+ data = data.encode("utf-8")
58
+ return hashlib.sha256(data).hexdigest()
59
+
60
+
61
+ def content_hash(document: Any) -> str:
62
+ """Deterministic content hash of one document (timestamps excluded)."""
63
+ return sha256_hex(canonical_json(strip_timestamps(document)))
agentspm/cli.py ADDED
@@ -0,0 +1,624 @@
1
+ """agentspm CLI (AK-06): scan -> evaluate -> report, plus export and doctor.
2
+
3
+ Pipeline (ADR-0001): **collect -> validate -> merge -> derive** (``scan``) ->
4
+ **evaluate** (pinned OPA, hard fail on any engine problem) -> **report**
5
+ (redacted by default; the full profile never leaves the evidence store).
6
+
7
+ Exit codes:
8
+
9
+ * ``scan``: non-zero ONLY for a configuration error (2), an empty graph
10
+ after the scan (1), or ``--strict`` with a degraded run (1). A failing
11
+ collector alone degrades to a partial scan — the previous snapshot stays
12
+ and the report shows the staleness.
13
+ * ``evaluate``: any OPA problem is a hard fail (1) — never default results.
14
+ * ``report``: refusing to write a full profile outside the evidence store,
15
+ or failing the redaction gate, exits 1.
16
+ * ``export``: fewer than two age recipients is refused (1).
17
+ * ``doctor``: any failing check exits 1 with a readable list.
18
+
19
+ Everything tenant-specific comes from the deployment YAML + ``AGENTSPM_*``
20
+ environment variables (FR-D5); this module never hard-codes tenant state.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import tarfile
29
+ import tempfile
30
+ import uuid
31
+ from collections.abc import Mapping
32
+ from datetime import UTC, datetime, timedelta
33
+ from pathlib import Path
34
+ from typing import Annotated, Any
35
+
36
+ import structlog
37
+ import typer
38
+
39
+ from agentspm.collectors import (
40
+ Collector,
41
+ CollectorConfigError,
42
+ build_enabled_collectors,
43
+ collector_registry,
44
+ load_config,
45
+ run_collectors,
46
+ )
47
+ from agentspm.doctor import (
48
+ MIN_AGE_RECIPIENTS,
49
+ age_recipients_from_config,
50
+ has_failures,
51
+ resolve_age_path,
52
+ run_doctor,
53
+ )
54
+ from agentspm.evaluate import EvaluateError, find_policies_dir, run_evaluate
55
+ from agentspm.graph.derive import derive
56
+ from agentspm.graph.store import GraphStore
57
+ from agentspm.report import (
58
+ META_DROPPED_DOCUMENTS,
59
+ META_SCAN_COLLECTORS,
60
+ META_SCAN_ID,
61
+ META_SCAN_TIME,
62
+ META_SYNTHETIC_DATA,
63
+ REPORT_FORMATS,
64
+ REPORT_PROFILES,
65
+ ReportError,
66
+ active_retention,
67
+ build_report,
68
+ load_controls,
69
+ resolve_evidence_store,
70
+ )
71
+ from agentspm.report.json import render_json
72
+ from agentspm.report.md import render_md
73
+ from agentspm.report.redact import (
74
+ Pseudonymizer,
75
+ RedactionError,
76
+ assert_no_personal_identifiers,
77
+ configure_logging,
78
+ load_or_create_key,
79
+ redact_structure,
80
+ resolve_pseudo_key_path,
81
+ )
82
+ from agentspm.report.sarif import render_sarif
83
+
84
+ log = structlog.get_logger(__name__)
85
+
86
+ ENV_CONFIG = "AGENTSPM_CONFIG"
87
+ ENV_DB = "AGENTSPM_DB"
88
+
89
+ DEFAULT_DB = "agentspm.sqlite"
90
+ _CONFIG_FALLBACKS = ("agentspm.yaml", "deploy/agentspm.example.yaml")
91
+ #: Ten z fallbacku, ktery je soucasti repa a nikdy neni provozni konfiguraci.
92
+ _EXAMPLE_CONFIG = "deploy/agentspm.example.yaml"
93
+
94
+ EXIT_ERROR = 1
95
+ EXIT_CONFIG = 2
96
+
97
+ MOCK_COLLECTOR = "mock-gateway"
98
+
99
+ app = typer.Typer(
100
+ name="agentspm",
101
+ help="Agent security posture: collect, evaluate against the policy pack, report.",
102
+ no_args_is_help=True,
103
+ add_completion=False,
104
+ )
105
+
106
+ ConfigOption = Annotated[
107
+ Path | None,
108
+ typer.Option(
109
+ "--config",
110
+ envvar=ENV_CONFIG,
111
+ help="Deployment YAML (default: ./agentspm.yaml, then the example config).",
112
+ ),
113
+ ]
114
+ DbOption = Annotated[
115
+ Path | None,
116
+ typer.Option("--db", envvar=ENV_DB, help="SQLite graph database path."),
117
+ ]
118
+
119
+
120
+ def _fail(message: str, exit_code: int = EXIT_ERROR) -> typer.Exit:
121
+ typer.echo(f"error: {message}", err=True)
122
+ return typer.Exit(exit_code)
123
+
124
+
125
+ def _load_cli_config(config_opt: Path | None) -> tuple[dict[str, Any], Path | None]:
126
+ """Resolve and load the deployment config; a missing default is fine."""
127
+ path: Path | None
128
+ if config_opt is not None:
129
+ if not config_opt.is_file():
130
+ raise _fail(f"config file not found: {config_opt}", EXIT_CONFIG)
131
+ path = config_opt
132
+ else:
133
+ path = next(
134
+ (Path(c) for c in _CONFIG_FALLBACKS if Path(c).is_file()), None
135
+ )
136
+ if path is None:
137
+ return {}, None
138
+ try:
139
+ return load_config(path), path
140
+ except (CollectorConfigError, OSError, ValueError) as exc:
141
+ raise _fail(f"cannot load config {path}: {exc}", EXIT_CONFIG) from exc
142
+
143
+
144
+ def _db_path(db_opt: Path | None, config: Mapping[str, Any]) -> Path:
145
+ if db_opt is not None:
146
+ return db_opt
147
+ configured = config.get("database")
148
+ if isinstance(configured, str) and configured:
149
+ return Path(configured)
150
+ return Path(DEFAULT_DB)
151
+
152
+
153
+ def _freshness_path(db_path: Path) -> Path:
154
+ return db_path.with_suffix(".freshness.json")
155
+
156
+
157
+ @app.callback()
158
+ def _main() -> None:
159
+ configure_logging()
160
+
161
+
162
+ # -- scan ---------------------------------------------------------------------
163
+
164
+
165
+ def _build_selected_collectors(
166
+ names: list[str], config: Mapping[str, Any]
167
+ ) -> list[Collector]:
168
+ registry = collector_registry()
169
+ sections_raw = config.get("collectors")
170
+ sections = sections_raw if isinstance(sections_raw, Mapping) else {}
171
+ collectors: list[Collector] = []
172
+ for name in names:
173
+ factory = registry.get(name)
174
+ if factory is None:
175
+ raise _fail(
176
+ f"unknown collector {name!r}; registered: "
177
+ f"{', '.join(sorted(registry))}",
178
+ EXIT_CONFIG,
179
+ )
180
+ section_raw = sections.get(name)
181
+ section = dict(section_raw) if isinstance(section_raw, Mapping) else {}
182
+ try:
183
+ collectors.append(factory(section, dict(os.environ)))
184
+ except CollectorConfigError as exc:
185
+ raise _fail(str(exc), EXIT_CONFIG) from exc
186
+ return collectors
187
+
188
+
189
+ @app.command()
190
+ def scan(
191
+ collector: Annotated[
192
+ list[str] | None,
193
+ typer.Option(
194
+ "--collector",
195
+ help="Run only this collector (repeatable); default: all enabled in the config.",
196
+ ),
197
+ ] = None,
198
+ config: ConfigOption = None,
199
+ db: DbOption = None,
200
+ strict: Annotated[
201
+ bool,
202
+ typer.Option(
203
+ "--strict",
204
+ help="Exit non-zero when any collector failed, degraded to partial, "
205
+ "or dropped documents.",
206
+ ),
207
+ ] = False,
208
+ ) -> None:
209
+ """Collect -> validate -> merge -> derive into the SQLite graph."""
210
+ cfg, _ = _load_cli_config(config)
211
+ database = _db_path(db, cfg)
212
+ store = GraphStore(database)
213
+
214
+ if collector:
215
+ collectors = _build_selected_collectors(list(collector), cfg)
216
+ else:
217
+ try:
218
+ collectors = build_enabled_collectors(cfg)
219
+ except CollectorConfigError as exc:
220
+ raise _fail(str(exc), EXIT_CONFIG) from exc
221
+ if not collectors:
222
+ typer.echo(
223
+ "no collectors enabled in the config and none selected via "
224
+ "--collector; nothing was collected",
225
+ err=True,
226
+ )
227
+
228
+ outcomes = run_collectors(store, collectors)
229
+ events = [event for outcome in outcomes for event in outcome.events]
230
+ derive_result = derive(store, events)
231
+
232
+ scan_id = str(uuid.uuid4())
233
+ scan_time = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
234
+ collectors_meta = {
235
+ outcome.collector: {
236
+ "status": outcome.status,
237
+ "documents": outcome.documents,
238
+ "dropped": outcome.dropped,
239
+ "warnings": list(outcome.warnings),
240
+ }
241
+ for outcome in outcomes
242
+ }
243
+ dropped_total = sum(outcome.dropped for outcome in outcomes)
244
+ dropped_by_collector = {
245
+ outcome.collector: outcome.dropped for outcome in outcomes if outcome.dropped
246
+ }
247
+ synthetic = MOCK_COLLECTOR in {c.name for c in collectors} or (
248
+ MOCK_COLLECTOR in store.collectors_with_contributions()
249
+ )
250
+
251
+ store.set_meta(META_SCAN_ID, scan_id)
252
+ store.set_meta(META_SCAN_TIME, scan_time)
253
+ store.set_meta_json(META_SCAN_COLLECTORS, collectors_meta)
254
+ store.set_meta_json(
255
+ META_DROPPED_DOCUMENTS,
256
+ {"total": dropped_total, "by_collector": dropped_by_collector},
257
+ )
258
+ store.set_meta(META_SYNTHETIC_DATA, "true" if synthetic else "false")
259
+
260
+ freshness = {
261
+ "scan_id": scan_id,
262
+ "scan_time": scan_time,
263
+ "graph_hash": derive_result.graph_hash,
264
+ "collectors": collectors_meta,
265
+ }
266
+ freshness_path = _freshness_path(database)
267
+ freshness_path.write_text(
268
+ json.dumps(freshness, indent=2, sort_keys=True) + "\n", encoding="utf-8"
269
+ )
270
+
271
+ assets_total = len(store.list_asset_rows())
272
+ for outcome in outcomes:
273
+ typer.echo(
274
+ f"collector {outcome.collector}: {outcome.status} "
275
+ f"({outcome.documents} documents, {outcome.dropped} dropped)"
276
+ )
277
+ typer.echo(
278
+ f"scan {scan_id}: {assets_total} assets in graph, "
279
+ f"graph_hash {derive_result.graph_hash[:16]}…, freshness {freshness_path}"
280
+ )
281
+
282
+ if assets_total == 0:
283
+ raise _fail("scan produced an empty graph (0 assets)")
284
+ if strict:
285
+ degraded = [
286
+ outcome.collector
287
+ for outcome in outcomes
288
+ if outcome.status in ("failed", "partial") or outcome.dropped
289
+ ]
290
+ if degraded:
291
+ raise _fail(
292
+ "--strict: degraded collectors: " + ", ".join(sorted(degraded))
293
+ )
294
+
295
+
296
+ # -- evaluate -------------------------------------------------------------------
297
+
298
+
299
+ @app.command()
300
+ def evaluate(
301
+ config: ConfigOption = None,
302
+ db: DbOption = None,
303
+ ) -> None:
304
+ """Evaluate the graph with the pinned OPA binary (hard fail on any error)."""
305
+ cfg, _ = _load_cli_config(config)
306
+ store = GraphStore(_db_path(db, cfg))
307
+ try:
308
+ result = run_evaluate(store, cfg)
309
+ except EvaluateError as exc:
310
+ raise _fail(str(exc)) from exc
311
+ typer.echo(
312
+ f"evaluate: {result.findings_stored} findings stored "
313
+ f"({result.findings_dropped} dropped by validation), "
314
+ f"policy pack {result.policy_pack_version}, opa {result.opa_path}"
315
+ )
316
+
317
+
318
+ # -- report ---------------------------------------------------------------------
319
+
320
+
321
+ def _render(
322
+ report_model: dict[str, Any],
323
+ fmt: str,
324
+ controls: list[dict[str, Any]] | None,
325
+ ) -> str:
326
+ if fmt == "md":
327
+ return render_md(report_model, controls)
328
+ if fmt == "json":
329
+ return render_json(report_model)
330
+ if controls is None:
331
+ raise _fail("SARIF output needs controls.yaml (policy pack not found)")
332
+ return render_sarif(report_model, controls)
333
+
334
+
335
+ def _is_within(path: Path, root: Path) -> bool:
336
+ try:
337
+ path.resolve().relative_to(root.resolve())
338
+ except ValueError:
339
+ return False
340
+ return True
341
+
342
+
343
+ @app.command()
344
+ def report(
345
+ fmt: Annotated[
346
+ str, typer.Option("--format", help="Output format: md, json or sarif.")
347
+ ] = "md",
348
+ profile: Annotated[
349
+ str,
350
+ typer.Option(
351
+ "--profile",
352
+ help="redacted (default, pseudonymized) or full (evidence store only).",
353
+ ),
354
+ ] = "redacted",
355
+ out: Annotated[Path | None, typer.Option("--out", help="Output path.")] = None,
356
+ config: ConfigOption = None,
357
+ db: DbOption = None,
358
+ ) -> None:
359
+ """Render the posture report (redacted by default)."""
360
+ if fmt not in REPORT_FORMATS:
361
+ raise _fail(f"unknown format {fmt!r} (use md, json or sarif)", EXIT_CONFIG)
362
+ if profile not in REPORT_PROFILES:
363
+ raise _fail(f"unknown profile {profile!r} (use redacted or full)", EXIT_CONFIG)
364
+ cfg, _ = _load_cli_config(config)
365
+ store = GraphStore(_db_path(db, cfg))
366
+ try:
367
+ report_model = build_report(store, cfg, profile=profile)
368
+ except ReportError as exc:
369
+ raise _fail(str(exc)) from exc
370
+
371
+ controls: list[dict[str, Any]] | None
372
+ try:
373
+ controls = load_controls(find_policies_dir(cfg))
374
+ except (EvaluateError, ReportError) as exc:
375
+ if fmt == "sarif":
376
+ raise _fail(f"SARIF needs the policy pack metadata: {exc}") from exc
377
+ controls = None
378
+
379
+ evidence_store = resolve_evidence_store(cfg)
380
+ extension = {"md": "md", "json": "json", "sarif": "sarif"}[fmt]
381
+
382
+ if profile == "full":
383
+ # The full profile NEVER leaves the evidence store (GAP-GDPR-03).
384
+ if evidence_store is None:
385
+ raise _fail(
386
+ "full profile requires an evidence store (set evidence_store "
387
+ "in the config or AGENTSPM_EVIDENCE_STORE)"
388
+ )
389
+ target = (
390
+ out
391
+ if out is not None
392
+ else evidence_store / "reports" / f"agentspm-report-full.{extension}"
393
+ )
394
+ if not _is_within(target, evidence_store):
395
+ raise _fail(
396
+ f"refusing to write a full-profile report outside the evidence "
397
+ f"store ({evidence_store}); target was {target}"
398
+ )
399
+ rendered = _render(report_model, fmt, controls)
400
+ else:
401
+ target = out if out is not None else Path(f"agentspm-report.{extension}")
402
+ key_path = resolve_pseudo_key_path(evidence_store)
403
+ if key_path is None:
404
+ raise _fail(
405
+ "redacted profile needs the pseudonymization key: set "
406
+ "AGENTSPM_PSEUDO_KEY_PATH or configure the evidence store "
407
+ "(evidence_store / AGENTSPM_EVIDENCE_STORE)"
408
+ )
409
+ pseudo = Pseudonymizer(load_or_create_key(key_path))
410
+ report_model = redact_structure(report_model, pseudo)
411
+ rendered = _render(report_model, fmt, controls)
412
+ try:
413
+ assert_no_personal_identifiers(rendered)
414
+ except RedactionError as exc:
415
+ raise _fail(str(exc)) from exc
416
+ # alias -> value mapping is personal data: evidence store only (RP-05).
417
+ pseudo.write_mapping(key_path.parent)
418
+
419
+ target.parent.mkdir(parents=True, exist_ok=True)
420
+ target.write_text(rendered, encoding="utf-8")
421
+ if profile == "full":
422
+ target.parent.chmod(0o700)
423
+ target.chmod(0o600)
424
+ typer.echo(f"report written: {target} (format {fmt}, profile {profile})")
425
+
426
+
427
+ # -- export ---------------------------------------------------------------------
428
+
429
+
430
+ @app.command()
431
+ def export(
432
+ encrypt: Annotated[
433
+ list[str] | None,
434
+ typer.Option(
435
+ "--encrypt",
436
+ help="age recipient as age:<recipient> (repeatable; >= 2 required).",
437
+ ),
438
+ ] = None,
439
+ out: Annotated[
440
+ Path | None, typer.Option("--out", help="Output path for the .tar.age snapshot.")
441
+ ] = None,
442
+ config: ConfigOption = None,
443
+ db: DbOption = None,
444
+ ) -> None:
445
+ """Write an age-encrypted snapshot (database + reports) — >= 2 recipients."""
446
+ cfg, _ = _load_cli_config(config)
447
+ recipients: list[str] = []
448
+ for entry in encrypt or []:
449
+ if not entry.startswith("age:"):
450
+ raise _fail(
451
+ f"--encrypt expects age:<recipient>, got {entry!r}", EXIT_CONFIG
452
+ )
453
+ value = entry[len("age:") :]
454
+ if value and value not in recipients:
455
+ recipients.append(value)
456
+ for value in age_recipients_from_config(cfg):
457
+ if value not in recipients:
458
+ recipients.append(value)
459
+ if len(recipients) < MIN_AGE_RECIPIENTS:
460
+ raise _fail(
461
+ f"export refused: {len(recipients)} age recipient(s) provided, "
462
+ f"at least {MIN_AGE_RECIPIENTS} required (a single lost key must "
463
+ "not strand the evidence)"
464
+ )
465
+
466
+ age_path = resolve_age_path(dict(os.environ))
467
+ if age_path is None:
468
+ raise _fail(
469
+ "age binary not found (ASSUM-08: the export uses the system age "
470
+ "binary — install age or set AGENTSPM_AGE_PATH)"
471
+ )
472
+
473
+ database = _db_path(db, cfg)
474
+ if not database.is_file():
475
+ raise _fail(f"database not found: {database} — run `agentspm scan` first")
476
+ evidence_store = resolve_evidence_store(cfg)
477
+ if out is not None:
478
+ target = out
479
+ elif evidence_store is not None:
480
+ target = evidence_store / "exports" / "agentspm-export.tar.age"
481
+ else:
482
+ raise _fail(
483
+ "no --out given and no evidence store configured for the default "
484
+ "export location"
485
+ )
486
+
487
+ members: list[tuple[Path, str]] = [(database, f"db/{database.name}")]
488
+ freshness_path = _freshness_path(database)
489
+ if freshness_path.is_file():
490
+ members.append((freshness_path, f"db/{freshness_path.name}"))
491
+ if evidence_store is not None:
492
+ reports_dir = evidence_store / "reports"
493
+ if reports_dir.is_dir():
494
+ members.extend(
495
+ (path, f"reports/{path.relative_to(reports_dir)}")
496
+ for path in sorted(reports_dir.rglob("*"))
497
+ if path.is_file()
498
+ )
499
+
500
+ target.parent.mkdir(parents=True, exist_ok=True)
501
+ if evidence_store is not None and _is_within(target, evidence_store):
502
+ target.parent.chmod(0o700)
503
+ with tempfile.TemporaryDirectory(prefix="agentspm-export-") as tmp:
504
+ tar_path = Path(tmp) / "snapshot.tar"
505
+ with tarfile.open(tar_path, "w") as archive:
506
+ for source, arcname in members:
507
+ archive.add(source, arcname=arcname)
508
+ argv = [str(age_path)]
509
+ for recipient in recipients:
510
+ argv += ["-r", recipient]
511
+ argv += ["-o", str(target), str(tar_path)]
512
+ completed = subprocess.run(argv, capture_output=True, text=True) # no shell
513
+ if completed.returncode != 0:
514
+ raise _fail(f"age encryption failed: {completed.stderr.strip()}")
515
+ target.chmod(0o600)
516
+ typer.echo(
517
+ f"export written: {target} ({len(members)} file(s), "
518
+ f"{len(recipients)} age recipients)"
519
+ )
520
+
521
+
522
+ # -- doctor ---------------------------------------------------------------------
523
+
524
+
525
+ # -- purge ----------------------------------------------------------------------
526
+
527
+
528
+ def _parse_older_than(value: str) -> int:
529
+ """``90d`` / ``90`` -> days; anything else is a config error."""
530
+ text = value.strip().lower()
531
+ if text.endswith("d"):
532
+ text = text[:-1]
533
+ if not text.isdigit():
534
+ raise _fail(
535
+ f"invalid --older-than {value!r}: use a day count like 90 or 90d",
536
+ EXIT_CONFIG,
537
+ )
538
+ return int(text)
539
+
540
+
541
+ @app.command()
542
+ def purge(
543
+ older_than: Annotated[
544
+ str | None,
545
+ typer.Option(
546
+ "--older-than",
547
+ help="Age threshold in days (e.g. 90d). Default: retention.raw_days "
548
+ "from the config (the same number the report header prints).",
549
+ ),
550
+ ] = None,
551
+ dry_run: Annotated[
552
+ bool,
553
+ typer.Option("--dry-run", help="Count what would be deleted; delete nothing."),
554
+ ] = False,
555
+ config: ConfigOption = None,
556
+ db: DbOption = None,
557
+ ) -> None:
558
+ """Enforce the declared retention: delete graph rows and full-profile
559
+ reports older than the threshold (GAP-GDPR-08).
560
+
561
+ Scope: the graph database and ``<evidence_store>/reports/``. NOT covered:
562
+ redacted reports written outside the evidence store (the tool cannot know
563
+ where they live) and the pseudonymization key/mapping (deleting the mapping
564
+ would break linkability of reports still within retention). Rows without a
565
+ parseable timestamp are never deleted. A real purge writes an enforcement
566
+ marker into ``meta`` (key ``purge:last``).
567
+ """
568
+ from agentspm.purge import (
569
+ purge_report_files,
570
+ purge_store,
571
+ write_purge_marker,
572
+ )
573
+
574
+ cfg, _ = _load_cli_config(config)
575
+ retention = active_retention(cfg)
576
+ days = _parse_older_than(older_than) if older_than is not None else retention["raw_days"]
577
+ cutoff = datetime.now(tz=UTC) - timedelta(days=days)
578
+
579
+ store = GraphStore(_db_path(db, cfg))
580
+ result = purge_store(store, cutoff, dry_run=dry_run)
581
+ result.report_files = purge_report_files(
582
+ resolve_evidence_store(cfg), cutoff, dry_run=dry_run
583
+ )
584
+
585
+ verb = "would delete" if dry_run else "deleted"
586
+ typer.echo(
587
+ f"purge ({'dry run, ' if dry_run else ''}older than {days}d, "
588
+ f"cutoff {result.cutoff}):"
589
+ )
590
+ typer.echo(
591
+ f" rows {verb}: assets={result.assets} contributions={result.contributions} "
592
+ f"edges={result.edges} provenance={result.provenance} findings={result.findings}"
593
+ )
594
+ typer.echo(f" report files {verb}: {len(result.report_files)}")
595
+ for path in result.report_files:
596
+ typer.echo(f" {path}")
597
+ if not dry_run:
598
+ write_purge_marker(store, result)
599
+ typer.echo(" enforcement marker written (meta key purge:last)")
600
+
601
+
602
+ @app.command()
603
+ def doctor(config: ConfigOption = None) -> None:
604
+ """Preflight checks: OPA pin, capabilities, permissions, age, credentials."""
605
+ cfg, config_path = _load_cli_config(config)
606
+ is_example = config is None and config_path is not None and (
607
+ config_path.as_posix() == _EXAMPLE_CONFIG
608
+ )
609
+ results = run_doctor(cfg, config_path=config_path, config_is_example=is_example)
610
+ for result in results:
611
+ typer.echo(str(result))
612
+ failures = [result for result in results if result.status == "fail"]
613
+ if has_failures(results):
614
+ typer.echo(
615
+ f"doctor: {len(failures)} check(s) failed "
616
+ f"({', '.join(result.name for result in failures)})",
617
+ err=True,
618
+ )
619
+ raise typer.Exit(EXIT_ERROR)
620
+ typer.echo("doctor: all checks passed")
621
+
622
+
623
+ if __name__ == "__main__": # pragma: no cover
624
+ app()