seizu-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,939 @@
1
+ """Pydantic models for the Seizu reporting YAML config format.
2
+
3
+ This module is the single authoritative source for the reporting dashboard
4
+ configuration schema. It is used by both the ``reporting`` backend and the
5
+ ``seizu_cli`` CLI; neither package defines these models independently.
6
+ """
7
+
8
+ import re
9
+ from typing import Any, Literal
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, Field, field_validator, model_validator
13
+
14
+ LOWER_SNAKE_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
15
+
16
+
17
+ def validate_lower_snake_id(value: str) -> str:
18
+ if not LOWER_SNAKE_ID_RE.fullmatch(value):
19
+ raise ValueError("must be lower_snake_case matching ^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
20
+ return value
21
+
22
+
23
+ class InputDefault(BaseModel):
24
+ label: str = Field(
25
+ description="The label for the default.",
26
+ )
27
+
28
+ value: str = Field(
29
+ description="The value for the default.",
30
+ )
31
+
32
+
33
+ class Input(BaseModel):
34
+ input_id: str = Field(
35
+ description="Reference to the query in the inputs section.",
36
+ examples=["cve_base_severity"],
37
+ )
38
+
39
+ label: str = Field(
40
+ description="The label to use for the select element.",
41
+ examples=["CVE base severity"],
42
+ )
43
+
44
+ type: Literal["autocomplete", "text"] = Field(
45
+ description="The type of input to use.",
46
+ examples=["autocomplete"],
47
+ )
48
+
49
+ cypher: str | None = Field(
50
+ default=None,
51
+ description="The Cypher query to execute. Must return ``value``.",
52
+ examples=[
53
+ """
54
+ .. code-block:: cypher
55
+
56
+ MATCH (c:CVE)
57
+ RETURN DISTINCT c.base_severity AS value
58
+ """
59
+ ],
60
+ )
61
+
62
+ default: InputDefault | None = Field(
63
+ default=None,
64
+ description="The default value to set if no value is selected.",
65
+ examples=[
66
+ """
67
+ .. code-block:: yaml
68
+
69
+ label: (all)
70
+ value: .*
71
+ """
72
+ ],
73
+ )
74
+
75
+ size: int | None = Field(
76
+ default=2,
77
+ description="The size of the input element.",
78
+ examples=["2"],
79
+ )
80
+
81
+
82
+ class BarPanelSettings(BaseModel):
83
+ legend: str | None = Field(
84
+ default=None,
85
+ description=("The type of legend to use; ``row`` or ``column``. If unset, then no legend will be used."),
86
+ )
87
+
88
+
89
+ class PiePanelSettings(BaseModel):
90
+ legend: str | None = Field(
91
+ default=None,
92
+ description=(
93
+ "The type of legend to use; ``row`` or ``column``. If unset,"
94
+ " then no legend will be used, and arc labels will be used"
95
+ " instead."
96
+ ),
97
+ )
98
+
99
+
100
+ class GraphPanelSettings(BaseModel):
101
+ node_label: str | None = Field(
102
+ default=None,
103
+ description=("The node property to display as the node label. Defaults to ``label`` if unset."),
104
+ )
105
+
106
+ node_color_by: str | None = Field(
107
+ default=None,
108
+ description=("The node property to use for color grouping. Defaults to ``group`` if unset."),
109
+ )
110
+
111
+
112
+ class ProgressPanelSettings(BaseModel):
113
+ show_label: bool | None = Field(
114
+ default=None,
115
+ description=(
116
+ "Whether to render the ``numerator / denominator`` label above"
117
+ " the progress wheel. Defaults to ``True`` when unset; pass"
118
+ " ``False`` to hide the label and show only the wheel and"
119
+ " percentage."
120
+ ),
121
+ )
122
+
123
+
124
+ class PanelThreshold(BaseModel):
125
+ value: float = Field(
126
+ description=(
127
+ "The threshold trigger value. For ``count`` panels, the metric"
128
+ " is the raw total. For ``progress`` panels, the metric is the"
129
+ " completion percentage (0-100)."
130
+ ),
131
+ examples=[70],
132
+ )
133
+
134
+ color: str = Field(
135
+ description=(
136
+ "The color to apply when the metric matches this threshold. A CSS color string (e.g. ``#F44336``, ``red``)."
137
+ ),
138
+ examples=["#F44336"],
139
+ )
140
+
141
+
142
+ class PanelParam(BaseModel):
143
+ name: str = Field(
144
+ description="The parameter name to use when passing this input into the query.",
145
+ examples=["severity"],
146
+ )
147
+
148
+ input_id: str | None = Field(
149
+ default=None,
150
+ description="Reference to the query in the inputs section.",
151
+ examples=["cve_base_severity"],
152
+ )
153
+
154
+ value: Any | None = Field(
155
+ default=None,
156
+ description="The parameter value to pass into the query.",
157
+ examples=[
158
+ """
159
+ .. code-block:: yaml
160
+
161
+ params:
162
+ - name: integrityImpact
163
+ value: HIGH
164
+ """
165
+ ],
166
+ )
167
+
168
+
169
+ class Panel(BaseModel):
170
+ type: Literal[
171
+ "table",
172
+ "vertical-table",
173
+ "count",
174
+ "bar",
175
+ "pie",
176
+ "graph",
177
+ "progress",
178
+ "markdown",
179
+ ] = Field(
180
+ description="The type of panel to use.",
181
+ examples=["table"],
182
+ )
183
+
184
+ @model_validator(mode="before")
185
+ @classmethod
186
+ def reject_legacy_size(cls, data: Any) -> Any:
187
+ if isinstance(data, dict) and "size" in data:
188
+ raise ValueError("Panel field 'size' is no longer supported; use 'w' for width.")
189
+ return data
190
+
191
+ cypher: str | None = Field(
192
+ default=None,
193
+ description="A reference to a cypher from the cypher section of the configuration.",
194
+ examples=["cves"],
195
+ )
196
+
197
+ details_cypher: str | None = Field(
198
+ default=None,
199
+ description=(
200
+ "A reference to a cypher from the cypher section of the configuration."
201
+ " Used in the details section of the panel, as a table."
202
+ " The query can return rows in any of the formats supported by the"
203
+ " ``table`` panel type."
204
+ ),
205
+ examples=["cves-details"],
206
+ )
207
+
208
+ params: list[PanelParam] = Field(
209
+ default_factory=list,
210
+ description=(
211
+ "A list of parameters to send into the query. The parameters can"
212
+ " directly have values, or can be a reference to an input."
213
+ ),
214
+ examples=[
215
+ """
216
+ .. code-block:: yaml
217
+
218
+ params:
219
+ - name: severity
220
+ input_id: cve_base_severity
221
+ - name: integrityImpact
222
+ value: HIGH
223
+ """
224
+ ],
225
+ )
226
+
227
+ caption: str | None = Field(
228
+ default=None,
229
+ description="The caption to use for the panel.",
230
+ examples=["Critical CVEs"],
231
+ )
232
+
233
+ hide_caption: bool | None = Field(
234
+ default=None,
235
+ description="When True, the panel caption is not rendered in view mode.",
236
+ examples=["true"],
237
+ )
238
+
239
+ table_id: str | None = Field(
240
+ default=None,
241
+ description=(
242
+ "The cypher attribute to use for the table's unique ID, if using a"
243
+ " type of table or vertical-table. If not set, a random ID will be"
244
+ " generated. A table_id should be set for ``vertical-table``, or"
245
+ " the panel will have a random ID used as the caption."
246
+ ),
247
+ examples=["cve_id"],
248
+ )
249
+
250
+ markdown: str | None = Field(
251
+ default=None,
252
+ description=("The markdown to use for the panel. Only used for type ``markdown``."),
253
+ examples=[
254
+ """
255
+ .. code-block:: markdown
256
+
257
+ ## Affects
258
+
259
+ Versions x.x.x - x.x.x
260
+
261
+ ## Recommended action
262
+
263
+ Upgrade to the latest version of the software.
264
+ """
265
+ ],
266
+ )
267
+
268
+ w: int | None = Field(
269
+ default=None,
270
+ description="The width of the panel in grid columns (1-12). Used by react-grid-layout.",
271
+ examples=["3"],
272
+ )
273
+
274
+ h: int | None = Field(
275
+ default=None,
276
+ description=(
277
+ "The height of the panel in grid row units (each row is 48px)."
278
+ " When unset, the frontend derives a sensible default per panel"
279
+ " type."
280
+ ),
281
+ examples=["8"],
282
+ )
283
+
284
+ x: int | None = Field(
285
+ default=None,
286
+ description=(
287
+ "The column index (0-based) within the row's grid. When unset,"
288
+ " the frontend packs panels left-to-right automatically."
289
+ ),
290
+ examples=["0"],
291
+ )
292
+
293
+ y: int | None = Field(
294
+ default=None,
295
+ description=(
296
+ "The row index within the row's grid. Always 0 for single-row layouts; reserved for future multi-row use."
297
+ ),
298
+ examples=["0"],
299
+ )
300
+
301
+ min_h: int | None = Field(
302
+ default=None,
303
+ description=(
304
+ "Optional minimum height in grid row units. Enforced by react-grid-layout when the user resizes the panel."
305
+ ),
306
+ examples=["4"],
307
+ )
308
+
309
+ auto_height: bool | None = Field(
310
+ default=None,
311
+ description=(
312
+ "When ``True``, the panel renders at its natural content height"
313
+ " instead of filling its grid cell. Only meaningful for"
314
+ " ``markdown`` and ``vertical-table`` panel types; ignored"
315
+ " elsewhere."
316
+ ),
317
+ examples=["true"],
318
+ )
319
+
320
+ threshold: float | None = Field(
321
+ default=None,
322
+ description=(
323
+ "(Legacy) Single threshold value for ``count`` / ``progress``"
324
+ " panels. New configs should use ``thresholds``; this field is"
325
+ " retained as a fallback when ``thresholds`` is unset."
326
+ ),
327
+ examples=["70"],
328
+ )
329
+
330
+ thresholds: list[PanelThreshold] | None = Field(
331
+ default=None,
332
+ description=(
333
+ "Ordered list of ``{value, color}`` pairs for ``count`` and"
334
+ " ``progress`` panels. The applicable color is the color of the"
335
+ " threshold with the highest ``value`` that is less than or"
336
+ " equal to the metric (raw total for ``count``, completion"
337
+ " percentage for ``progress``). When unset, falls back to the"
338
+ " legacy ``threshold`` field."
339
+ ),
340
+ examples=[
341
+ """
342
+ .. code-block:: yaml
343
+
344
+ thresholds:
345
+ - value: 0
346
+ color: "#F44336"
347
+ - value: 70
348
+ color: "#4CAF50"
349
+ """
350
+ ],
351
+ )
352
+
353
+ bar_settings: BarPanelSettings | None = Field(
354
+ default=None,
355
+ description="Settings specific to bar panels.",
356
+ examples=[
357
+ """
358
+ .. code-block:: yaml
359
+
360
+ bar_settings:
361
+ legend: column
362
+ """
363
+ ],
364
+ )
365
+
366
+ pie_settings: PiePanelSettings | None = Field(
367
+ default=None,
368
+ description="Settings specific to pie panels.",
369
+ examples=[
370
+ """
371
+ .. code-block:: yaml
372
+
373
+ pie_settings:
374
+ legend: column
375
+ """
376
+ ],
377
+ )
378
+
379
+ graph_settings: GraphPanelSettings | None = Field(
380
+ default=None,
381
+ description="Settings specific to graph panels.",
382
+ examples=[
383
+ """
384
+ .. code-block:: yaml
385
+
386
+ graph_settings:
387
+ node_label: label
388
+ node_color_by: group
389
+ """
390
+ ],
391
+ )
392
+
393
+ progress_settings: ProgressPanelSettings | None = Field(
394
+ default=None,
395
+ description="Settings specific to progress panels.",
396
+ examples=[
397
+ """
398
+ .. code-block:: yaml
399
+
400
+ progress_settings:
401
+ show_label: false
402
+ """
403
+ ],
404
+ )
405
+
406
+
407
+ class Row(BaseModel):
408
+ name: str = Field(
409
+ description="The name of the row; shown as title above the row.",
410
+ examples=["CVEs"],
411
+ )
412
+
413
+ hide_header: bool | None = Field(
414
+ default=None,
415
+ description="When True, the row name header is not rendered in view mode.",
416
+ examples=["true"],
417
+ )
418
+
419
+ collapsible: bool | None = Field(
420
+ default=None,
421
+ description="When True, the row can be collapsed by clicking the header in view mode.",
422
+ examples=["true"],
423
+ )
424
+
425
+ panels: list[Panel] = Field(
426
+ description="The panels to show in the row.",
427
+ examples=[
428
+ """
429
+ .. code-block:: yaml
430
+
431
+ panels:
432
+ - cypher: cves-by-severity
433
+ details_cypher: cves-by-severity-details
434
+ type: count
435
+ params:
436
+ base_severity: CRITICAL
437
+ caption: Critical CVEs
438
+ w: 2
439
+ """
440
+ ],
441
+ )
442
+
443
+
444
+ class Report(BaseModel):
445
+ schema_version: int = Field(
446
+ default=1,
447
+ description=(
448
+ "The schema version of the report config. Increment when making breaking changes to the report schema."
449
+ ),
450
+ examples=[1],
451
+ )
452
+
453
+ pinned: bool | None = Field(
454
+ default=None,
455
+ description=(
456
+ "Optional seed metadata for dashboard navigation. When set, the"
457
+ " seeder pins or unpins the report after creating or updating it."
458
+ " This field is not stored inside report version configs."
459
+ ),
460
+ examples=[True],
461
+ )
462
+
463
+ name: str = Field(
464
+ description="The name of the report.",
465
+ examples=["CVEs"],
466
+ )
467
+
468
+ queries: dict[str, str] = Field(
469
+ default_factory=dict,
470
+ description=(
471
+ "Named Cypher queries for this report."
472
+ " Panel ``cypher`` fields may reference a key from this dict,"
473
+ " or provide a Cypher query string directly."
474
+ ),
475
+ examples=[
476
+ """
477
+ .. code-block:: yaml
478
+
479
+ queries:
480
+ cves-total: |-
481
+ MATCH (c:CVE)
482
+ RETURN count(c.id) AS total
483
+ """
484
+ ],
485
+ )
486
+
487
+ inputs: list[Input] = Field(
488
+ default_factory=list,
489
+ description="The inputs to use for the report.",
490
+ examples=[
491
+ """
492
+ .. code-block:: yaml
493
+
494
+ inputs:
495
+ - input_id: cve_base_severity
496
+ cypher: |-
497
+ MATCH (c:CVE)
498
+ RETURN c.base_severity AS base_severity
499
+ default:
500
+ label: (all)
501
+ value: .*
502
+ label: Base Severity
503
+ type: autocomplete
504
+ size: 2
505
+ """
506
+ ],
507
+ )
508
+
509
+ rows: list[Row] = Field(
510
+ default_factory=list,
511
+ description="The rows of the report.",
512
+ examples=[
513
+ """
514
+ .. code-block:: yaml
515
+
516
+ rows:
517
+ - name: "CVEs"
518
+ panels:
519
+ - cypher: cves
520
+ type: table
521
+ params:
522
+ - name: severity
523
+ input_id: cve_base_severity
524
+ w: 12
525
+ """
526
+ ],
527
+ )
528
+
529
+
530
+ class ScheduledQueryWatchScan(BaseModel):
531
+ grouptype: str | None = Field(
532
+ default=".*",
533
+ description=(
534
+ "Match against the grouptype attribute of the SyncMetadata"
535
+ " node, as a regex. If not set, the query will match against"
536
+ " ``.*``."
537
+ ),
538
+ examples=["CVE"],
539
+ )
540
+
541
+ syncedtype: str | None = Field(
542
+ default=".*",
543
+ description=(
544
+ "Match against the syncedtype attribute of the SyncMetadata"
545
+ " node, as a regex. If not set, the query will match against"
546
+ " ``.*``."
547
+ ),
548
+ examples=["year"],
549
+ )
550
+
551
+ groupid: str | None = Field(
552
+ default=".*",
553
+ description=(
554
+ "Match against the groupid attribute of the SyncMetadata"
555
+ " node, as a regex. If not set, the query will match against"
556
+ " ``.*``."
557
+ ),
558
+ examples=["2019"],
559
+ )
560
+
561
+
562
+ class ScheduledQueryAction(BaseModel):
563
+ action_type: str = Field(
564
+ description="The type of action to perform.",
565
+ examples=["slack", "sqs"],
566
+ )
567
+
568
+ action_config: dict[str, Any] = Field(
569
+ description=(
570
+ "The configuration for the action. See the documentation for the"
571
+ " relevant scheduled query module for information about the"
572
+ " configuration needed for each action type."
573
+ ),
574
+ examples=[
575
+ """
576
+ .. code-block:: yaml
577
+
578
+ action_config:
579
+ webhook_url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
580
+ channel: #cve
581
+ username: CVE
582
+ icon_emoji: :cve:
583
+ """
584
+ ],
585
+ )
586
+
587
+
588
+ class ScheduledQueryParam(BaseModel):
589
+ name: str = Field(
590
+ description="The parameter name to use when passing this input into the query.",
591
+ examples=["severity"],
592
+ )
593
+
594
+ value: Any = Field(
595
+ description="The parameter value to pass into the query.",
596
+ examples=[
597
+ """
598
+ .. code-block:: yaml
599
+
600
+ params:
601
+ - name: integrityImpact
602
+ value: HIGH
603
+ """
604
+ ],
605
+ )
606
+
607
+
608
+ class ScheduledQuery(BaseModel):
609
+ name: str = Field(
610
+ description="The name of the scheduled query.",
611
+ examples=["Recently published HIGH/CRITICAL CVEs"],
612
+ )
613
+
614
+ cypher: str = Field(
615
+ description="The cypher to use for the scheduled query.",
616
+ examples=["recent-cves"],
617
+ )
618
+
619
+ params: list[ScheduledQueryParam] = Field(
620
+ default_factory=list,
621
+ description=(
622
+ "A dictionary of parameters to pass to the cypher query. The keys"
623
+ " are the variable names, and the values are the values to pass."
624
+ ),
625
+ examples=[
626
+ """
627
+ .. code-block:: yaml
628
+
629
+ params:
630
+ - name: syncedtype
631
+ value:
632
+ - recent
633
+ - name: base_severity
634
+ value:
635
+ - HIGH
636
+ - CRITICAL
637
+ """
638
+ ],
639
+ )
640
+
641
+ frequency: int | None = Field(
642
+ default=None,
643
+ description=("The frequency of the scheduled query in minutes. Mutually exclusive with ``watch_scans``."),
644
+ examples=["1440"],
645
+ )
646
+
647
+ watch_scans: list[ScheduledQueryWatchScan] = Field(
648
+ default_factory=list,
649
+ description=(
650
+ "The scans to watch for the scheduled query. Based on"
651
+ " SyncMetadata. Query will triger if any of the watched"
652
+ " scans listed are updated. Mutually exclusive with"
653
+ " ``frequency``."
654
+ ),
655
+ examples=[
656
+ """
657
+ .. code-block:: yaml
658
+
659
+ watch_scans:
660
+ - grouptype: CVE
661
+ syncedtype: recent
662
+ - grouptype: CVE
663
+ syncedtype: modified
664
+ """
665
+ ],
666
+ )
667
+
668
+ enabled: bool | None = Field(
669
+ default=True,
670
+ description=("Whether the scheduled query should be enabled. If not set, the scheduled query will be enabled."),
671
+ examples=["true"],
672
+ )
673
+
674
+ actions: list[ScheduledQueryAction] = Field(
675
+ default_factory=list,
676
+ description=("The actions to perform when the scheduled query is triggered."),
677
+ examples=[
678
+ """
679
+ .. code-block:: yaml
680
+
681
+ actions:
682
+ - action_type: slack
683
+ title: Recently published HIGH/CRITICAL CVEs
684
+ initial_comment: |
685
+ The following HIGH/CRITICAL CVEs have been published in the last 24 hours.
686
+ channels:
687
+ - C0000000000
688
+ """
689
+ ],
690
+ )
691
+
692
+
693
+ class ToolParamDef(BaseModel):
694
+ """Definition of a single parameter accepted by a tool."""
695
+
696
+ name: str
697
+ type: Literal["string", "integer", "float", "boolean"]
698
+ description: str = ""
699
+ required: bool = True
700
+ default: Any | None = None
701
+
702
+ @field_validator("name")
703
+ @classmethod
704
+ def validate_name(cls, v: str) -> str:
705
+ return validate_lower_snake_id(v)
706
+
707
+
708
+ class ToolDef(BaseModel):
709
+ """A tool definition within a toolset."""
710
+
711
+ name: str
712
+ description: str = ""
713
+ cypher: str
714
+ parameters: list[ToolParamDef] = Field(default_factory=list)
715
+ enabled: bool = True
716
+
717
+
718
+ class ToolsetDef(BaseModel):
719
+ """A toolset definition for MCP tool exposure."""
720
+
721
+ name: str
722
+ description: str = ""
723
+ enabled: bool = True
724
+ tools: dict[str, ToolDef] = Field(default_factory=dict)
725
+
726
+ @field_validator("tools")
727
+ @classmethod
728
+ def validate_tool_ids(cls, v: dict[str, ToolDef]) -> dict[str, ToolDef]:
729
+ for key in v:
730
+ validate_lower_snake_id(key)
731
+ return v
732
+
733
+
734
+ class SkillDef(BaseModel):
735
+ """A prompt template definition within a skillset."""
736
+
737
+ name: str
738
+ description: str = ""
739
+ template: str
740
+ parameters: list[ToolParamDef] = Field(default_factory=list)
741
+ triggers: list[str] = Field(default_factory=list)
742
+ tools_required: list[str] = Field(default_factory=list)
743
+ enabled: bool = True
744
+
745
+ @field_validator("triggers")
746
+ @classmethod
747
+ def validate_triggers(cls, v: list[str]) -> list[str]:
748
+ seen: set[str] = set()
749
+ for value in v:
750
+ if not value.strip():
751
+ raise ValueError("triggers entries must not be empty")
752
+ if value in seen:
753
+ raise ValueError("triggers entries must be unique")
754
+ seen.add(value)
755
+ return v
756
+
757
+ @field_validator("tools_required")
758
+ @classmethod
759
+ def validate_tools_required(cls, v: list[str]) -> list[str]:
760
+ seen: set[str] = set()
761
+ for value in v:
762
+ if not re.fullmatch(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*__[a-z][a-z0-9]*(?:_[a-z0-9]+)*$", value):
763
+ raise ValueError("tools_required entries must use MCP tool names like toolset_id__tool_id")
764
+ if value in seen:
765
+ raise ValueError("tools_required entries must be unique")
766
+ seen.add(value)
767
+ return v
768
+
769
+
770
+ class SkillsetDef(BaseModel):
771
+ """A skillset definition for MCP prompt exposure."""
772
+
773
+ name: str
774
+ description: str = ""
775
+ enabled: bool = True
776
+ skills: dict[str, SkillDef] = Field(default_factory=dict)
777
+
778
+ @field_validator("skills")
779
+ @classmethod
780
+ def validate_skill_ids(cls, v: dict[str, SkillDef]) -> dict[str, SkillDef]:
781
+ for key in v:
782
+ validate_lower_snake_id(key)
783
+ return v
784
+
785
+
786
+ class ReportingConfig(BaseModel):
787
+ queries: dict[str, str] = Field(
788
+ default_factory=dict,
789
+ description="The queries to use for the report.",
790
+ examples=[
791
+ """
792
+ .. code-block:: yaml
793
+
794
+ queries:
795
+ cves-severity-of-total: |-
796
+ MATCH (c:CVE)
797
+ WITH COUNT(DISTINCT c.id) AS denominator
798
+ MATCH (c:CVE)
799
+ WHERE c.base_severity = "CRITICAL"
800
+ RETURN count(DISTINCT c.id) AS numerator, denominator
801
+ """
802
+ ],
803
+ )
804
+
805
+ dashboard: str | None = Field(
806
+ default=None,
807
+ description=(
808
+ "Key of the report in the ``reports`` section to use as the default"
809
+ " dashboard. If unset, no report is displayed on the dashboard page."
810
+ ),
811
+ examples=["dashboard"],
812
+ )
813
+
814
+ reports: dict[str, Report] = Field(
815
+ default_factory=dict,
816
+ description="The reports to use for the report.",
817
+ examples=[
818
+ """
819
+ .. code-block:: yaml
820
+
821
+ reports:
822
+ cves:
823
+ name: CVEs
824
+ rows:
825
+ - name: CVEs
826
+ panels:
827
+ - cypher: cves
828
+ type: table
829
+ w: 12
830
+ """
831
+ ],
832
+ )
833
+
834
+ scheduled_queries: list[ScheduledQuery] = Field(
835
+ default_factory=list,
836
+ description="The scheduled queries to run.",
837
+ examples=[
838
+ """
839
+ .. code-block:: yaml
840
+
841
+ scheduled_queries:
842
+ - name: CVEs by severity
843
+ cypher: recent-cves
844
+ frequency: 1440
845
+ actions:
846
+ - action_type: slack
847
+ action_config:
848
+ title: Recently published HIGH/CRITICAL CVEs
849
+ """
850
+ ],
851
+ )
852
+
853
+ toolsets: dict[str, ToolsetDef] = Field(
854
+ default_factory=dict,
855
+ description="Toolset definitions for MCP tool exposure.",
856
+ examples=[
857
+ """
858
+ .. code-block:: yaml
859
+
860
+ toolsets:
861
+ my-toolset:
862
+ name: My Toolset
863
+ description: A collection of graph tools
864
+ enabled: true
865
+ tools:
866
+ my-tool:
867
+ name: My Tool
868
+ description: Counts nodes
869
+ cypher: MATCH (n) RETURN count(n) AS total
870
+ enabled: true
871
+ """
872
+ ],
873
+ )
874
+
875
+ skillsets: dict[str, SkillsetDef] = Field(
876
+ default_factory=dict,
877
+ description="Skillset definitions for MCP prompt exposure.",
878
+ examples=[
879
+ """
880
+ .. code-block:: yaml
881
+
882
+ skillsets:
883
+ investigations:
884
+ name: Investigations
885
+ description: Prompt templates for graph investigations
886
+ enabled: true
887
+ skills:
888
+ summarize_node:
889
+ name: Summarize node
890
+ template: Summarize {{node_id}} for a security analyst.
891
+ parameters:
892
+ - name: node_id
893
+ type: string
894
+ """
895
+ ],
896
+ )
897
+
898
+ @field_validator("toolsets")
899
+ @classmethod
900
+ def validate_toolset_ids(cls, v: dict[str, ToolsetDef]) -> dict[str, ToolsetDef]:
901
+ for key in v:
902
+ validate_lower_snake_id(key)
903
+ return v
904
+
905
+ @field_validator("skillsets")
906
+ @classmethod
907
+ def validate_skillset_ids(cls, v: dict[str, SkillsetDef]) -> dict[str, SkillsetDef]:
908
+ for key in v:
909
+ validate_lower_snake_id(key)
910
+ return v
911
+
912
+ @field_validator("scheduled_queries", mode="before")
913
+ @classmethod
914
+ def coerce_scheduled_queries(cls, v: Any) -> Any:
915
+ """Accept old dict format (key -> ScheduledQuery) as well as the new list format."""
916
+ if isinstance(v, dict):
917
+ return list(v.values())
918
+ return v
919
+
920
+
921
+ def output_json_schema() -> dict[str, Any]:
922
+ schema = ReportingConfig.model_json_schema()
923
+ schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
924
+ return schema
925
+
926
+
927
+ def load_file(path: str) -> ReportingConfig:
928
+ """Load a ``ReportingConfig`` from a YAML file at *path*."""
929
+ with open(path) as f:
930
+ return ReportingConfig.model_validate(yaml.safe_load(f))
931
+
932
+
933
+ def dump_yaml(config: ReportingConfig) -> str:
934
+ """Serialise *config* to a YAML string."""
935
+ return yaml.dump(
936
+ config.model_dump(),
937
+ default_flow_style=False,
938
+ allow_unicode=True,
939
+ )