agentic-threat-hunting-framework 0.4.0__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.
- {agentic_threat_hunting_framework-0.4.0.dist-info → agentic_threat_hunting_framework-0.5.0.dist-info}/METADATA +1 -1
- {agentic_threat_hunting_framework-0.4.0.dist-info → agentic_threat_hunting_framework-0.5.0.dist-info}/RECORD +19 -11
- {agentic_threat_hunting_framework-0.4.0.dist-info → agentic_threat_hunting_framework-0.5.0.dist-info}/WHEEL +1 -1
- athf/agents/base.py +2 -2
- athf/cli.py +10 -2
- athf/commands/__init__.py +6 -1
- athf/commands/similar.py +2 -2
- athf/core/clickhouse_connection.py +396 -0
- athf/core/metrics_tracker.py +518 -0
- athf/core/query_executor.py +169 -0
- athf/core/query_parser.py +203 -0
- athf/core/query_suggester.py +235 -0
- athf/core/query_validator.py +240 -0
- athf/core/session_manager.py +764 -0
- athf/core/web_search.py +1 -1
- athf/plugin_system.py +48 -0
- {agentic_threat_hunting_framework-0.4.0.dist-info → agentic_threat_hunting_framework-0.5.0.dist-info}/entry_points.txt +0 -0
- {agentic_threat_hunting_framework-0.4.0.dist-info → agentic_threat_hunting_framework-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {agentic_threat_hunting_framework-0.4.0.dist-info → agentic_threat_hunting_framework-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -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)
|