agentic-threat-hunting-framework 0.3.1__py3-none-any.whl → 0.5.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,764 @@
1
+ """Hunt session management for capturing execution context."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import yaml
9
+
10
+
11
+ class SessionError(Exception):
12
+ """Session management error."""
13
+
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class QueryLog:
19
+ """Log entry for a query execution."""
20
+
21
+ id: str
22
+ timestamp: str
23
+ sql: str
24
+ result_count: int
25
+ duration_ms: int
26
+ outcome: str # success | refined | abandoned
27
+ note: Optional[str] = None
28
+
29
+
30
+ @dataclass
31
+ class DecisionLog:
32
+ """Log entry for a decision point."""
33
+
34
+ timestamp: str
35
+ phase: str # hypothesis | analysis | pivot
36
+ decision: str
37
+ rationale: Optional[str] = None
38
+ alternatives: Optional[List[str]] = None
39
+
40
+
41
+ @dataclass
42
+ class FindingLog:
43
+ """Log entry for a finding."""
44
+
45
+ timestamp: str
46
+ finding_type: str # tp | fp | pattern | suspicious
47
+ description: str
48
+ count: int = 1
49
+ escalated: bool = False
50
+
51
+
52
+ @dataclass
53
+ class Session:
54
+ """Hunt execution session."""
55
+
56
+ hunt_id: str
57
+ session_id: str
58
+ start_time: str
59
+ end_time: Optional[str] = None
60
+ duration_min: Optional[int] = None
61
+ queries: List[QueryLog] = field(default_factory=list)
62
+ decisions: List[DecisionLog] = field(default_factory=list)
63
+ findings: List[FindingLog] = field(default_factory=list)
64
+
65
+ @property
66
+ def query_count(self) -> int:
67
+ """Get total number of queries."""
68
+ return len(self.queries)
69
+
70
+ @property
71
+ def finding_count(self) -> int:
72
+ """Get total number of findings."""
73
+ return len(self.findings)
74
+
75
+ @property
76
+ def tp_count(self) -> int:
77
+ """Get count of true positive findings."""
78
+ return len([f for f in self.findings if f.finding_type == "tp"])
79
+
80
+ @property
81
+ def fp_count(self) -> int:
82
+ """Get count of false positive findings."""
83
+ return len([f for f in self.findings if f.finding_type == "fp"])
84
+
85
+ def to_metadata_dict(self) -> Dict[str, Any]:
86
+ """Convert to metadata dict for session.yaml (committed)."""
87
+ return {
88
+ "hunt_id": self.hunt_id,
89
+ "session_id": self.session_id,
90
+ "start_time": self.start_time,
91
+ "end_time": self.end_time,
92
+ "duration_min": self.duration_min,
93
+ "query_count": self.query_count,
94
+ "finding_count": self.finding_count,
95
+ "tp_count": self.tp_count,
96
+ "fp_count": self.fp_count,
97
+ }
98
+
99
+
100
+ class SessionManager:
101
+ """Singleton manager for hunt sessions.
102
+
103
+ Handles session lifecycle:
104
+ - start_session(): Create new session
105
+ - log_query(): Record query execution
106
+ - log_decision(): Record decision point
107
+ - log_finding(): Record finding
108
+ - end_session(): Close session, generate summary
109
+ """
110
+
111
+ _instance: Optional["SessionManager"] = None
112
+ _active_session: Optional[Session] = None
113
+ _sessions_dir: Path = Path("sessions")
114
+
115
+ def __new__(cls) -> "SessionManager":
116
+ """Ensure only one instance exists (singleton pattern)."""
117
+ if cls._instance is None:
118
+ cls._instance = super().__new__(cls)
119
+ return cls._instance
120
+
121
+ @classmethod
122
+ def get_instance(cls) -> "SessionManager":
123
+ """Get the singleton instance."""
124
+ if cls._instance is None:
125
+ cls._instance = cls()
126
+ return cls._instance
127
+
128
+ def _get_active_file(self) -> Path:
129
+ """Get path to .active file."""
130
+ return self._sessions_dir / ".active"
131
+
132
+ def _get_session_dir(self, session_id: str) -> Path:
133
+ """Get path to session directory."""
134
+ return self._sessions_dir / session_id
135
+
136
+ def _now_iso(self) -> str:
137
+ """Get current timestamp in ISO format."""
138
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
139
+
140
+ def _today_str(self) -> str:
141
+ """Get today's date as string."""
142
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
143
+
144
+ def get_active_session(self) -> Optional[Session]:
145
+ """Get currently active session, if any."""
146
+ if self._active_session:
147
+ return self._active_session
148
+
149
+ # Try to load from .active file
150
+ active_file = self._get_active_file()
151
+ if active_file.exists():
152
+ try:
153
+ session_id = active_file.read_text().strip()
154
+ if session_id:
155
+ return self._load_session(session_id)
156
+ except Exception:
157
+ pass
158
+
159
+ return None
160
+
161
+ def _load_session(self, session_id: str) -> Optional[Session]:
162
+ """Load session from disk."""
163
+ session_dir = self._get_session_dir(session_id)
164
+ session_file = session_dir / "session.yaml"
165
+
166
+ if not session_file.exists():
167
+ return None
168
+
169
+ try:
170
+ with open(session_file, "r") as f:
171
+ data = yaml.safe_load(f)
172
+
173
+ session = Session(
174
+ hunt_id=data["hunt_id"],
175
+ session_id=data["session_id"],
176
+ start_time=data["start_time"],
177
+ end_time=data.get("end_time"),
178
+ duration_min=data.get("duration_min"),
179
+ )
180
+
181
+ # Load queries
182
+ queries_file = session_dir / "queries.yaml"
183
+ if queries_file.exists():
184
+ with open(queries_file, "r") as f:
185
+ queries_data = yaml.safe_load(f) or {}
186
+ for q in queries_data.get("queries", []):
187
+ session.queries.append(
188
+ QueryLog(
189
+ id=q["id"],
190
+ timestamp=q["timestamp"],
191
+ sql=q["sql"],
192
+ result_count=q["result_count"],
193
+ duration_ms=q["duration_ms"],
194
+ outcome=q["outcome"],
195
+ note=q.get("note"),
196
+ )
197
+ )
198
+
199
+ # Load decisions
200
+ decisions_file = session_dir / "decisions.yaml"
201
+ if decisions_file.exists():
202
+ with open(decisions_file, "r") as f:
203
+ decisions_data = yaml.safe_load(f) or {}
204
+ for d in decisions_data.get("decisions", []):
205
+ session.decisions.append(
206
+ DecisionLog(
207
+ timestamp=d["timestamp"],
208
+ phase=d["phase"],
209
+ decision=d["decision"],
210
+ rationale=d.get("rationale"),
211
+ alternatives=d.get("alternatives"),
212
+ )
213
+ )
214
+
215
+ # Load findings
216
+ findings_file = session_dir / "findings.yaml"
217
+ if findings_file.exists():
218
+ with open(findings_file, "r") as f:
219
+ findings_data = yaml.safe_load(f) or {}
220
+ for f_item in findings_data.get("findings", []):
221
+ session.findings.append(
222
+ FindingLog(
223
+ timestamp=f_item["timestamp"],
224
+ finding_type=f_item["type"],
225
+ description=f_item["description"],
226
+ count=f_item.get("count", 1),
227
+ escalated=f_item.get("escalated", False),
228
+ )
229
+ )
230
+
231
+ self._active_session = session
232
+ return session
233
+
234
+ except Exception as e:
235
+ raise SessionError(f"Failed to load session {session_id}: {e}")
236
+
237
+ def start_session(self, hunt_id: str) -> Session:
238
+ """Start a new hunt session.
239
+
240
+ Args:
241
+ hunt_id: Hunt identifier (e.g., H-0025)
242
+
243
+ Returns:
244
+ New Session instance
245
+
246
+ Raises:
247
+ SessionError: If session already active or creation fails
248
+ """
249
+ # Check for existing active session
250
+ existing = self.get_active_session()
251
+ if existing:
252
+ raise SessionError(f"Session already active: {existing.session_id}. " f"End it with 'athf session end' first.")
253
+
254
+ # Create session ID
255
+ session_id = f"{hunt_id}-{self._today_str()}"
256
+
257
+ # Handle multiple sessions on same day
258
+ session_dir = self._get_session_dir(session_id)
259
+ counter = 1
260
+ while session_dir.exists():
261
+ counter += 1
262
+ session_id = f"{hunt_id}-{self._today_str()}-{counter}"
263
+ session_dir = self._get_session_dir(session_id)
264
+
265
+ # Create session directory
266
+ session_dir.mkdir(parents=True, exist_ok=True)
267
+
268
+ # Create session
269
+ session = Session(
270
+ hunt_id=hunt_id,
271
+ session_id=session_id,
272
+ start_time=self._now_iso(),
273
+ )
274
+
275
+ # Write session.yaml
276
+ self._save_session_metadata(session)
277
+
278
+ # Write .active pointer
279
+ self._get_active_file().write_text(session_id)
280
+
281
+ self._active_session = session
282
+ return session
283
+
284
+ def _save_session_metadata(self, session: Session) -> None:
285
+ """Save session metadata to session.yaml."""
286
+ session_dir = self._get_session_dir(session.session_id)
287
+ session_file = session_dir / "session.yaml"
288
+
289
+ # Get base metadata
290
+ metadata = session.to_metadata_dict()
291
+
292
+ # Add LLM metrics if session has ended (has end_time)
293
+ if session.end_time:
294
+ llm_metrics = self._get_session_llm_metrics(session)
295
+ metadata["llm_calls"] = llm_metrics.get("call_count", 0)
296
+ metadata["llm_total_tokens"] = llm_metrics.get("total_input_tokens", 0) + llm_metrics.get("total_output_tokens", 0)
297
+ metadata["llm_cost_usd"] = llm_metrics.get("total_cost_usd", 0.0)
298
+
299
+ with open(session_file, "w") as f:
300
+ yaml.dump(metadata, f, default_flow_style=False, sort_keys=False)
301
+
302
+ def log_query(
303
+ self,
304
+ sql: str,
305
+ result_count: int,
306
+ duration_ms: int = 0,
307
+ outcome: str = "success",
308
+ note: Optional[str] = None,
309
+ ) -> QueryLog:
310
+ """Log a query execution.
311
+
312
+ Args:
313
+ sql: SQL query executed
314
+ result_count: Number of rows returned
315
+ duration_ms: Query execution time in milliseconds
316
+ outcome: Query outcome (success, refined, abandoned)
317
+ note: Optional note about the query
318
+
319
+ Returns:
320
+ QueryLog entry
321
+
322
+ Raises:
323
+ SessionError: If no active session
324
+ """
325
+ session = self.get_active_session()
326
+ if not session:
327
+ raise SessionError("No active session. Start one with 'athf session start --hunt H-XXXX'")
328
+
329
+ # Generate query ID
330
+ query_id = f"q{len(session.queries) + 1:03d}"
331
+
332
+ query_log = QueryLog(
333
+ id=query_id,
334
+ timestamp=self._now_iso(),
335
+ sql=sql,
336
+ result_count=result_count,
337
+ duration_ms=duration_ms,
338
+ outcome=outcome,
339
+ note=note,
340
+ )
341
+
342
+ session.queries.append(query_log)
343
+ self._save_queries(session)
344
+ self._save_session_metadata(session)
345
+
346
+ return query_log
347
+
348
+ def _save_queries(self, session: Session) -> None:
349
+ """Save queries to queries.yaml."""
350
+ session_dir = self._get_session_dir(session.session_id)
351
+ queries_file = session_dir / "queries.yaml"
352
+
353
+ queries_data = {
354
+ "queries": [
355
+ {
356
+ "id": q.id,
357
+ "timestamp": q.timestamp,
358
+ "sql": q.sql,
359
+ "result_count": q.result_count,
360
+ "duration_ms": q.duration_ms,
361
+ "outcome": q.outcome,
362
+ "note": q.note,
363
+ }
364
+ for q in session.queries
365
+ ]
366
+ }
367
+
368
+ with open(queries_file, "w") as f:
369
+ yaml.dump(queries_data, f, default_flow_style=False, sort_keys=False)
370
+
371
+ def log_decision(
372
+ self,
373
+ phase: str,
374
+ decision: str,
375
+ rationale: Optional[str] = None,
376
+ alternatives: Optional[List[str]] = None,
377
+ ) -> DecisionLog:
378
+ """Log a decision point.
379
+
380
+ Args:
381
+ phase: Decision phase (hypothesis, analysis, pivot)
382
+ decision: The decision made
383
+ rationale: Why this decision was made
384
+ alternatives: Alternative options considered
385
+
386
+ Returns:
387
+ DecisionLog entry
388
+
389
+ Raises:
390
+ SessionError: If no active session
391
+ """
392
+ session = self.get_active_session()
393
+ if not session:
394
+ raise SessionError("No active session. Start one with 'athf session start --hunt H-XXXX'")
395
+
396
+ decision_log = DecisionLog(
397
+ timestamp=self._now_iso(),
398
+ phase=phase,
399
+ decision=decision,
400
+ rationale=rationale,
401
+ alternatives=alternatives,
402
+ )
403
+
404
+ session.decisions.append(decision_log)
405
+ self._save_decisions(session)
406
+
407
+ return decision_log
408
+
409
+ def _save_decisions(self, session: Session) -> None:
410
+ """Save decisions to decisions.yaml."""
411
+ session_dir = self._get_session_dir(session.session_id)
412
+ decisions_file = session_dir / "decisions.yaml"
413
+
414
+ decisions_data = {
415
+ "decisions": [
416
+ {
417
+ "timestamp": d.timestamp,
418
+ "phase": d.phase,
419
+ "decision": d.decision,
420
+ "rationale": d.rationale,
421
+ "alternatives": d.alternatives,
422
+ }
423
+ for d in session.decisions
424
+ ]
425
+ }
426
+
427
+ with open(decisions_file, "w") as f:
428
+ yaml.dump(decisions_data, f, default_flow_style=False, sort_keys=False)
429
+
430
+ def log_finding(
431
+ self,
432
+ finding_type: str,
433
+ description: str,
434
+ count: int = 1,
435
+ escalated: bool = False,
436
+ ) -> FindingLog:
437
+ """Log a finding.
438
+
439
+ Args:
440
+ finding_type: Type of finding (tp, fp, pattern, suspicious)
441
+ description: Description of the finding
442
+ count: Number of instances found
443
+ escalated: Whether finding was escalated
444
+
445
+ Returns:
446
+ FindingLog entry
447
+
448
+ Raises:
449
+ SessionError: If no active session
450
+ """
451
+ session = self.get_active_session()
452
+ if not session:
453
+ raise SessionError("No active session. Start one with 'athf session start --hunt H-XXXX'")
454
+
455
+ finding_log = FindingLog(
456
+ timestamp=self._now_iso(),
457
+ finding_type=finding_type,
458
+ description=description,
459
+ count=count,
460
+ escalated=escalated,
461
+ )
462
+
463
+ session.findings.append(finding_log)
464
+ self._save_findings(session)
465
+ self._save_session_metadata(session)
466
+
467
+ return finding_log
468
+
469
+ def _save_findings(self, session: Session) -> None:
470
+ """Save findings to findings.yaml."""
471
+ session_dir = self._get_session_dir(session.session_id)
472
+ findings_file = session_dir / "findings.yaml"
473
+
474
+ findings_data = {
475
+ "findings": [
476
+ {
477
+ "timestamp": f.timestamp,
478
+ "type": f.finding_type,
479
+ "description": f.description,
480
+ "count": f.count,
481
+ "escalated": f.escalated,
482
+ }
483
+ for f in session.findings
484
+ ]
485
+ }
486
+
487
+ with open(findings_file, "w") as f:
488
+ yaml.dump(findings_data, f, default_flow_style=False, sort_keys=False)
489
+
490
+ def end_session(self, generate_summary: bool = True) -> Session:
491
+ """End the active session.
492
+
493
+ Args:
494
+ generate_summary: Whether to generate summary.md
495
+
496
+ Returns:
497
+ Completed Session
498
+
499
+ Raises:
500
+ SessionError: If no active session
501
+ """
502
+ session = self.get_active_session()
503
+ if not session:
504
+ raise SessionError("No active session to end.")
505
+
506
+ # Set end time and calculate duration
507
+ session.end_time = self._now_iso()
508
+
509
+ start = datetime.fromisoformat(session.start_time.replace("Z", "+00:00"))
510
+ end = datetime.fromisoformat(session.end_time.replace("Z", "+00:00"))
511
+ session.duration_min = int((end - start).total_seconds() / 60)
512
+
513
+ # Save final metadata
514
+ self._save_session_metadata(session)
515
+
516
+ # Generate summary
517
+ if generate_summary:
518
+ self._generate_summary(session)
519
+
520
+ # Clear active pointer
521
+ active_file = self._get_active_file()
522
+ if active_file.exists():
523
+ active_file.unlink()
524
+
525
+ self._active_session = None
526
+
527
+ return session
528
+
529
+ def _get_session_llm_metrics(self, session: Session) -> Dict[str, Any]:
530
+ """Get LLM metrics for session, including no_session calls in time window.
531
+
532
+ Extends the start time 30 minutes backwards to capture LLM calls
533
+ that occurred before the session started (e.g., hypothesis generation
534
+ that ran before 'athf session start').
535
+
536
+ Args:
537
+ session: Session to get metrics for
538
+
539
+ Returns:
540
+ Dict with LLM metrics:
541
+ {
542
+ "call_count": N,
543
+ "total_input_tokens": N,
544
+ "total_output_tokens": N,
545
+ "total_cost_usd": N.NNNN,
546
+ }
547
+ """
548
+ try:
549
+ from datetime import timedelta
550
+
551
+ from athf.core.metrics_tracker import MetricsTracker
552
+
553
+ tracker = MetricsTracker.get_instance()
554
+
555
+ # Extend start time 30 min backwards to capture pre-session LLM calls
556
+ # (e.g., hypothesis generation that ran before 'athf session start')
557
+ start_dt = datetime.fromisoformat(session.start_time.replace("Z", "+00:00"))
558
+ extended_start = (start_dt - timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ")
559
+
560
+ metrics = tracker.get_metrics_in_time_window(
561
+ session.session_id,
562
+ extended_start,
563
+ session.end_time or self._now_iso(),
564
+ )
565
+ bedrock_metrics: dict[str, Any] = metrics.get("bedrock", {})
566
+ return bedrock_metrics
567
+ except Exception:
568
+ return {
569
+ "call_count": 0,
570
+ "total_input_tokens": 0,
571
+ "total_output_tokens": 0,
572
+ "total_cost_usd": 0.0,
573
+ }
574
+
575
+ def _generate_summary(self, session: Session) -> None:
576
+ """Generate summary.md for the session."""
577
+ session_dir = self._get_session_dir(session.session_id)
578
+ summary_file = session_dir / "summary.md"
579
+
580
+ # Format duration
581
+ if session.duration_min is not None:
582
+ if session.duration_min == 0:
583
+ duration_str = "< 1m"
584
+ else:
585
+ hours = session.duration_min // 60
586
+ mins = session.duration_min % 60
587
+ if hours > 0:
588
+ duration_str = f"{hours}h {mins}m"
589
+ else:
590
+ duration_str = f"{mins}m"
591
+ else:
592
+ duration_str = "Unknown"
593
+
594
+ # Extract date from session_id (e.g., H-TEST-2025-12-29 -> 2025-12-29)
595
+ parts = session.session_id.split("-")
596
+ if len(parts) >= 4:
597
+ # Format: H-XXXX-YYYY-MM-DD or H-TEST-YYYY-MM-DD
598
+ date_str = "-".join(parts[-3:]) # Last 3 parts are the date
599
+ else:
600
+ date_str = session.session_id
601
+
602
+ # Get LLM metrics for the session
603
+ llm_metrics = self._get_session_llm_metrics(session)
604
+ llm_cost = llm_metrics.get("total_cost_usd", 0.0)
605
+ llm_calls = llm_metrics.get("call_count", 0)
606
+ llm_input_tokens = llm_metrics.get("total_input_tokens", 0)
607
+ llm_output_tokens = llm_metrics.get("total_output_tokens", 0)
608
+
609
+ # Build header with LLM cost if any
610
+ header_parts = [
611
+ f"**Duration:** {duration_str}",
612
+ f"**Queries:** {session.query_count}",
613
+ f"**Findings:** {session.tp_count} TP, {session.fp_count} FP",
614
+ ]
615
+ if llm_cost > 0:
616
+ header_parts.append(f"**LLM Cost:** ${llm_cost:.4f}")
617
+
618
+ # Build summary
619
+ lines = [
620
+ f"# Session: {session.hunt_id} ({date_str})",
621
+ "",
622
+ " | ".join(header_parts),
623
+ "",
624
+ ]
625
+
626
+ # Final successful query
627
+ successful_queries = [q for q in session.queries if q.outcome == "success"]
628
+ if successful_queries:
629
+ final_query = successful_queries[-1]
630
+ lines.extend(
631
+ [
632
+ "## Final Query",
633
+ "",
634
+ "```sql",
635
+ final_query.sql.strip(),
636
+ "```",
637
+ "",
638
+ ]
639
+ )
640
+
641
+ # Key decisions
642
+ if session.decisions:
643
+ lines.extend(
644
+ [
645
+ "## Key Decisions",
646
+ "",
647
+ ]
648
+ )
649
+ for d in session.decisions:
650
+ lines.append(f"- **{d.phase.title()}:** {d.decision}")
651
+ if d.rationale:
652
+ lines.append(f" - Rationale: {d.rationale}")
653
+ lines.append("")
654
+
655
+ # Findings
656
+ if session.findings:
657
+ lines.extend(
658
+ [
659
+ "## Findings",
660
+ "",
661
+ ]
662
+ )
663
+ for finding in session.findings:
664
+ prefix = finding.finding_type.upper()
665
+ escalated = " (escalated)" if finding.escalated else ""
666
+ lines.append(f"- **{prefix}:** {finding.description} ({finding.count} instances){escalated}")
667
+ lines.append("")
668
+
669
+ # Lessons (extracted from decisions with rationale)
670
+ lessons = [d for d in session.decisions if d.rationale]
671
+ if lessons:
672
+ lines.extend(
673
+ [
674
+ "## Lessons",
675
+ "",
676
+ ]
677
+ )
678
+ for d in lessons:
679
+ lines.append(f"- {d.rationale}")
680
+ lines.append("")
681
+
682
+ # Calculate query execution metrics
683
+ total_query_time_ms = sum(q.duration_ms for q in session.queries if q.duration_ms)
684
+ avg_query_time_ms = (
685
+ total_query_time_ms // session.query_count if session.query_count > 0 else 0
686
+ )
687
+
688
+ # Execution Metrics section
689
+ if llm_calls > 0 or session.query_count > 0:
690
+ lines.extend(
691
+ [
692
+ "## Execution Metrics",
693
+ "",
694
+ "| Resource | Metric | Value |",
695
+ "|----------|--------|-------|",
696
+ ]
697
+ )
698
+
699
+ # Query metrics (if any queries executed)
700
+ if session.query_count > 0:
701
+ total_query_time_sec = total_query_time_ms / 1000
702
+ lines.append(f"| ClickHouse | Queries | {session.query_count} |")
703
+ lines.append(f"| ClickHouse | Total Time | {total_query_time_sec:.1f}s |")
704
+ lines.append(f"| ClickHouse | Avg Time | {avg_query_time_ms}ms |")
705
+
706
+ # LLM metrics (if any LLM calls)
707
+ if llm_calls > 0:
708
+ lines.append(f"| LLM | Calls | {llm_calls} |")
709
+ lines.append(f"| LLM | Input Tokens | {llm_input_tokens:,} |")
710
+ lines.append(f"| LLM | Output Tokens | {llm_output_tokens:,} |")
711
+ lines.append(f"| LLM | Cost | ${llm_cost:.4f} |")
712
+
713
+ lines.append("")
714
+
715
+ with open(summary_file, "w") as f:
716
+ f.write("\n".join(lines))
717
+
718
+ def list_sessions(self, hunt_id: Optional[str] = None) -> List[Dict[str, Any]]:
719
+ """List all sessions, optionally filtered by hunt.
720
+
721
+ Args:
722
+ hunt_id: Optional hunt ID to filter by
723
+
724
+ Returns:
725
+ List of session metadata dicts
726
+ """
727
+ sessions = []
728
+
729
+ for session_dir in self._sessions_dir.iterdir():
730
+ if not session_dir.is_dir():
731
+ continue
732
+ if session_dir.name.startswith("."):
733
+ continue
734
+
735
+ session_file = session_dir / "session.yaml"
736
+ if not session_file.exists():
737
+ continue
738
+
739
+ try:
740
+ with open(session_file, "r") as f:
741
+ data = yaml.safe_load(f)
742
+
743
+ if hunt_id and data.get("hunt_id") != hunt_id:
744
+ continue
745
+
746
+ sessions.append(data)
747
+ except Exception:
748
+ continue
749
+
750
+ # Sort by start_time descending (most recent first)
751
+ sessions.sort(key=lambda x: x.get("start_time", ""), reverse=True)
752
+
753
+ return sessions
754
+
755
+ def get_session(self, session_id: str) -> Optional[Session]:
756
+ """Get a specific session by ID.
757
+
758
+ Args:
759
+ session_id: Session identifier
760
+
761
+ Returns:
762
+ Session if found, None otherwise
763
+ """
764
+ return self._load_session(session_id)