agentic-threat-hunting-framework 0.3.0__py3-none-any.whl → 0.4.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,762 @@
1
+ """Hunt researcher agent - LLM-powered thorough research before hunting.
2
+
3
+ Implements a structured 5-skill research methodology:
4
+ 1. System Research - How does the technology/process normally work?
5
+ 2. Adversary Tradecraft - How do adversaries abuse it? (web search)
6
+ 3. Telemetry Mapping - What OCSF fields and data sources capture this?
7
+ 4. Related Work - What past hunts/investigations are relevant?
8
+ 5. Synthesis - Key findings, gaps, recommended focus areas
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from athf.agents.base import AgentResult, LLMAgent
19
+
20
+
21
+ @dataclass
22
+ class ResearchInput:
23
+ """Input for hunt research."""
24
+
25
+ topic: str # Research topic (e.g., "LSASS memory dumping")
26
+ mitre_technique: Optional[str] = None # Optional T-code to focus research
27
+ depth: str = "advanced" # "basic" (5 min) or "advanced" (15-20 min)
28
+ include_past_hunts: bool = True
29
+ include_telemetry_mapping: bool = True
30
+ web_search_enabled: bool = True # Can be disabled for offline mode
31
+
32
+
33
+ @dataclass
34
+ class ResearchSkillOutput:
35
+ """Output from a single research skill."""
36
+
37
+ skill_name: str # e.g., "system_research", "adversary_tradecraft"
38
+ summary: str
39
+ key_findings: List[str]
40
+ sources: List[Dict[str, str]] # {"title": "", "url": "", "snippet": ""}
41
+ confidence: float # 0-1 confidence in findings
42
+ duration_ms: int = 0
43
+
44
+
45
+ @dataclass
46
+ class ResearchOutput:
47
+ """Complete research output following OTR-inspired 5 skills."""
48
+
49
+ research_id: str # R-XXXX format
50
+ topic: str
51
+ mitre_techniques: List[str]
52
+
53
+ # 5 OTR-inspired research skills
54
+ system_research: ResearchSkillOutput
55
+ adversary_tradecraft: ResearchSkillOutput
56
+ telemetry_mapping: ResearchSkillOutput
57
+ related_work: ResearchSkillOutput
58
+ synthesis: ResearchSkillOutput
59
+
60
+ # Synthesis outputs
61
+ recommended_hypothesis: Optional[str] = None
62
+ data_source_availability: Dict[str, bool] = field(default_factory=dict)
63
+ estimated_hunt_complexity: str = "medium" # low/medium/high
64
+ gaps_identified: List[str] = field(default_factory=list)
65
+
66
+ # Cost tracking
67
+ total_duration_ms: int = 0
68
+ web_searches_performed: int = 0
69
+ llm_calls: int = 0
70
+ total_cost_usd: float = 0.0
71
+
72
+
73
+ class HuntResearcherAgent(LLMAgent[ResearchInput, ResearchOutput]):
74
+ """Performs thorough research before hunt creation.
75
+
76
+ Implements a structured 5-skill research methodology:
77
+ 1. System Research - How does the technology/process normally work?
78
+ 2. Adversary Tradecraft - How do adversaries abuse it? (web search)
79
+ 3. Telemetry Mapping - What OCSF fields and data sources capture this?
80
+ 4. Related Work - What past hunts/investigations are relevant?
81
+ 5. Research Synthesis - Key findings, gaps, recommended focus areas
82
+
83
+ Features:
84
+ - Web search via Tavily API for external threat intel
85
+ - OCSF schema awareness for telemetry mapping
86
+ - Past hunt correlation via similarity search
87
+ - Cost tracking across all operations
88
+ - Fallback to limited research when APIs unavailable
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ llm_enabled: bool = True,
94
+ tavily_api_key: Optional[str] = None,
95
+ ) -> None:
96
+ """Initialize researcher with optional API keys."""
97
+ super().__init__(llm_enabled=llm_enabled)
98
+ self.tavily_api_key = tavily_api_key or os.getenv("TAVILY_API_KEY")
99
+ self._search_client: Optional[Any] = None
100
+ self._total_cost = 0.0
101
+ self._llm_calls = 0
102
+ self._web_searches = 0
103
+
104
+ def _get_search_client(self) -> Optional[Any]:
105
+ """Get or create Tavily search client."""
106
+ if self._search_client is None and self.tavily_api_key:
107
+ try:
108
+ from athf.core.web_search import TavilySearchClient
109
+
110
+ self._search_client = TavilySearchClient(api_key=self.tavily_api_key)
111
+ except Exception:
112
+ pass
113
+ return self._search_client
114
+
115
+ def execute(self, input_data: ResearchInput) -> AgentResult[ResearchOutput]:
116
+ """Execute complete research workflow.
117
+
118
+ Args:
119
+ input_data: Research input with topic, technique, and depth
120
+
121
+ Returns:
122
+ AgentResult with complete research output or error
123
+ """
124
+ start_time = time.time()
125
+ self._total_cost = 0.0
126
+ self._llm_calls = 0
127
+ self._web_searches = 0
128
+
129
+ try:
130
+ # Get next research ID
131
+ from athf.core.research_manager import ResearchManager
132
+
133
+ manager = ResearchManager()
134
+ research_id = manager.get_next_research_id()
135
+
136
+ # Determine search depth based on input
137
+ search_depth = "basic" if input_data.depth == "basic" else "advanced"
138
+
139
+ # Execute all 5 skills
140
+ skill_1 = self._skill_1_system_research(input_data.topic, search_depth)
141
+ skill_2 = self._skill_2_adversary_tradecraft(
142
+ input_data.topic,
143
+ input_data.mitre_technique,
144
+ search_depth,
145
+ input_data.web_search_enabled,
146
+ )
147
+ skill_3 = self._skill_3_telemetry_mapping(
148
+ input_data.topic,
149
+ input_data.mitre_technique,
150
+ )
151
+ skill_4 = self._skill_4_related_work(input_data.topic)
152
+ skill_5 = self._skill_5_synthesis(
153
+ input_data.topic,
154
+ input_data.mitre_technique,
155
+ [skill_1, skill_2, skill_3, skill_4],
156
+ )
157
+
158
+ # Extract synthesis outputs
159
+ mitre_techniques = [input_data.mitre_technique] if input_data.mitre_technique else []
160
+
161
+ # Build output
162
+ total_duration_ms = int((time.time() - start_time) * 1000)
163
+
164
+ output = ResearchOutput(
165
+ research_id=research_id,
166
+ topic=input_data.topic,
167
+ mitre_techniques=mitre_techniques,
168
+ system_research=skill_1,
169
+ adversary_tradecraft=skill_2,
170
+ telemetry_mapping=skill_3,
171
+ related_work=skill_4,
172
+ synthesis=skill_5,
173
+ recommended_hypothesis=self._extract_hypothesis(skill_5),
174
+ data_source_availability=self._extract_data_sources(skill_3),
175
+ estimated_hunt_complexity=self._estimate_complexity(skill_2, skill_3),
176
+ gaps_identified=self._extract_gaps(skill_5),
177
+ total_duration_ms=total_duration_ms,
178
+ web_searches_performed=self._web_searches,
179
+ llm_calls=self._llm_calls,
180
+ total_cost_usd=round(self._total_cost, 4),
181
+ )
182
+
183
+ return AgentResult(
184
+ success=True,
185
+ data=output,
186
+ error=None,
187
+ warnings=[],
188
+ metadata={
189
+ "research_id": research_id,
190
+ "duration_ms": total_duration_ms,
191
+ "web_searches": self._web_searches,
192
+ "llm_calls": self._llm_calls,
193
+ "cost_usd": round(self._total_cost, 4),
194
+ },
195
+ )
196
+
197
+ except Exception as e:
198
+ return AgentResult(
199
+ success=False,
200
+ data=None,
201
+ error=str(e),
202
+ warnings=[],
203
+ metadata={},
204
+ )
205
+
206
+ def _skill_1_system_research(
207
+ self,
208
+ topic: str,
209
+ search_depth: str,
210
+ ) -> ResearchSkillOutput:
211
+ """Skill 1: Research how the system/technology normally works.
212
+
213
+ Args:
214
+ topic: Research topic
215
+ search_depth: "basic" or "advanced"
216
+
217
+ Returns:
218
+ ResearchSkillOutput with system research findings
219
+ """
220
+ start_time = time.time()
221
+ sources: List[Dict[str, str]] = []
222
+ search_results = None
223
+
224
+ # Try web search for system internals
225
+ search_client = self._get_search_client()
226
+ if search_client:
227
+ try:
228
+ search_results = search_client.search_system_internals(topic, search_depth)
229
+ self._web_searches += 1
230
+
231
+ for result in search_results.results[:5]:
232
+ sources.append(
233
+ {
234
+ "title": result.title,
235
+ "url": result.url,
236
+ "snippet": result.content[:200] + "..." if len(result.content) > 200 else result.content,
237
+ }
238
+ )
239
+ except Exception:
240
+ pass
241
+
242
+ # Generate summary using LLM
243
+ if self.llm_enabled:
244
+ summary, key_findings = self._llm_summarize_system_research(topic, sources, search_results)
245
+ else:
246
+ summary = f"System research for {topic} - requires LLM for detailed analysis"
247
+ key_findings = ["LLM disabled - manual research required"]
248
+
249
+ duration_ms = int((time.time() - start_time) * 1000)
250
+
251
+ return ResearchSkillOutput(
252
+ skill_name="system_research",
253
+ summary=summary,
254
+ key_findings=key_findings,
255
+ sources=sources,
256
+ confidence=0.8 if sources else 0.5,
257
+ duration_ms=duration_ms,
258
+ )
259
+
260
+ def _skill_2_adversary_tradecraft(
261
+ self,
262
+ topic: str,
263
+ technique: Optional[str],
264
+ search_depth: str,
265
+ web_search_enabled: bool,
266
+ ) -> ResearchSkillOutput:
267
+ """Skill 2: Research adversary tradecraft via web search.
268
+
269
+ Args:
270
+ topic: Research topic
271
+ technique: Optional MITRE ATT&CK technique
272
+ search_depth: "basic" or "advanced"
273
+ web_search_enabled: Whether web search is enabled
274
+
275
+ Returns:
276
+ ResearchSkillOutput with adversary tradecraft findings
277
+ """
278
+ start_time = time.time()
279
+ sources: List[Dict[str, str]] = []
280
+ search_results = None
281
+
282
+ # Try web search for adversary tradecraft
283
+ search_client = self._get_search_client()
284
+ if search_client and web_search_enabled:
285
+ try:
286
+ search_results = search_client.search_adversary_tradecraft(topic, technique, search_depth)
287
+ self._web_searches += 1
288
+
289
+ for result in search_results.results[:7]:
290
+ sources.append(
291
+ {
292
+ "title": result.title,
293
+ "url": result.url,
294
+ "snippet": result.content[:200] + "..." if len(result.content) > 200 else result.content,
295
+ }
296
+ )
297
+ except Exception:
298
+ pass
299
+
300
+ # Generate summary using LLM
301
+ if self.llm_enabled:
302
+ summary, key_findings = self._llm_summarize_tradecraft(topic, technique, sources, search_results)
303
+ else:
304
+ summary = f"Adversary tradecraft for {topic} - requires LLM for detailed analysis"
305
+ key_findings = ["LLM disabled - manual research required"]
306
+
307
+ duration_ms = int((time.time() - start_time) * 1000)
308
+
309
+ return ResearchSkillOutput(
310
+ skill_name="adversary_tradecraft",
311
+ summary=summary,
312
+ key_findings=key_findings,
313
+ sources=sources,
314
+ confidence=0.85 if sources else 0.4,
315
+ duration_ms=duration_ms,
316
+ )
317
+
318
+ def _skill_3_telemetry_mapping(
319
+ self,
320
+ topic: str,
321
+ technique: Optional[str],
322
+ ) -> ResearchSkillOutput:
323
+ """Skill 3: Map to OCSF fields and available data sources.
324
+
325
+ Args:
326
+ topic: Research topic
327
+ technique: Optional MITRE ATT&CK technique
328
+
329
+ Returns:
330
+ ResearchSkillOutput with telemetry mapping
331
+ """
332
+ start_time = time.time()
333
+ sources: List[Dict[str, str]] = []
334
+
335
+ # Load OCSF schema reference
336
+ ocsf_schema = self._load_ocsf_schema()
337
+ environment_data = self._load_environment()
338
+
339
+ # Generate telemetry mapping using LLM
340
+ if self.llm_enabled:
341
+ summary, key_findings = self._llm_map_telemetry(topic, technique, ocsf_schema, environment_data)
342
+ else:
343
+ summary = f"Telemetry mapping for {topic} - requires LLM for detailed analysis"
344
+ key_findings = [
345
+ "Common fields: process.name, process.command_line, actor.user.name",
346
+ "Check OCSF_SCHEMA_REFERENCE.md for field population rates",
347
+ ]
348
+
349
+ # Add schema reference as source
350
+ sources.append(
351
+ {
352
+ "title": "OCSF Schema Reference",
353
+ "url": "knowledge/OCSF_SCHEMA_REFERENCE.md",
354
+ "snippet": "Internal schema documentation with field population rates",
355
+ }
356
+ )
357
+
358
+ duration_ms = int((time.time() - start_time) * 1000)
359
+
360
+ return ResearchSkillOutput(
361
+ skill_name="telemetry_mapping",
362
+ summary=summary,
363
+ key_findings=key_findings,
364
+ sources=sources,
365
+ confidence=0.9, # High confidence - based on internal schema
366
+ duration_ms=duration_ms,
367
+ )
368
+
369
+ def _skill_4_related_work(self, topic: str) -> ResearchSkillOutput:
370
+ """Skill 4: Find related past hunts and investigations.
371
+
372
+ Args:
373
+ topic: Research topic
374
+
375
+ Returns:
376
+ ResearchSkillOutput with related work
377
+ """
378
+ start_time = time.time()
379
+ sources: List[Dict[str, str]] = []
380
+ key_findings = []
381
+
382
+ # Use similarity search to find related hunts
383
+ try:
384
+ from athf.commands.similar import _find_similar_hunts
385
+
386
+ similar_hunts = _find_similar_hunts(topic, limit=5, threshold=0.1)
387
+
388
+ for hunt in similar_hunts:
389
+ sources.append(
390
+ {
391
+ "title": f"{hunt['hunt_id']}: {hunt['title']}",
392
+ "url": f"hunts/{hunt['hunt_id']}.md",
393
+ "snippet": f"Status: {hunt['status']}, Score: {hunt['similarity_score']:.3f}",
394
+ }
395
+ )
396
+ key_findings.append(f"{hunt['hunt_id']}: {hunt['title']} (similarity: {hunt['similarity_score']:.2f})")
397
+
398
+ except Exception:
399
+ key_findings.append("No similar hunts found or similarity search unavailable")
400
+
401
+ summary = f"Found {len(sources)} related hunts for {topic}"
402
+ if not sources:
403
+ summary = f"No related hunts found for {topic} - this may be a new research area"
404
+
405
+ duration_ms = int((time.time() - start_time) * 1000)
406
+
407
+ return ResearchSkillOutput(
408
+ skill_name="related_work",
409
+ summary=summary,
410
+ key_findings=key_findings if key_findings else ["No related past hunts found"],
411
+ sources=sources,
412
+ confidence=0.95, # High confidence - based on internal search
413
+ duration_ms=duration_ms,
414
+ )
415
+
416
+ def _skill_5_synthesis(
417
+ self,
418
+ topic: str,
419
+ technique: Optional[str],
420
+ skills: List[ResearchSkillOutput],
421
+ ) -> ResearchSkillOutput:
422
+ """Skill 5: Synthesize all research into actionable insights.
423
+
424
+ Args:
425
+ topic: Research topic
426
+ technique: Optional MITRE ATT&CK technique
427
+ skills: Outputs from skills 1-4
428
+
429
+ Returns:
430
+ ResearchSkillOutput with synthesis
431
+ """
432
+ start_time = time.time()
433
+
434
+ # Generate synthesis using LLM
435
+ if self.llm_enabled:
436
+ summary, key_findings = self._llm_synthesize(topic, technique, skills)
437
+ else:
438
+ summary = f"Research synthesis for {topic}"
439
+ key_findings = [
440
+ "LLM disabled - manual synthesis required",
441
+ "Review individual skill outputs for findings",
442
+ ]
443
+
444
+ duration_ms = int((time.time() - start_time) * 1000)
445
+
446
+ return ResearchSkillOutput(
447
+ skill_name="synthesis",
448
+ summary=summary,
449
+ key_findings=key_findings,
450
+ sources=[], # Synthesis doesn't have external sources
451
+ confidence=0.8,
452
+ duration_ms=duration_ms,
453
+ )
454
+
455
+ def _llm_summarize_system_research(
456
+ self,
457
+ topic: str,
458
+ sources: List[Dict[str, str]],
459
+ search_results: Optional[Any],
460
+ ) -> tuple[str, List[str]]:
461
+ """Use LLM to summarize system research findings."""
462
+ try:
463
+ client = self._get_llm_client()
464
+ if not client:
465
+ return f"System research for {topic}", ["LLM unavailable"]
466
+
467
+ # Build context from sources
468
+ context = ""
469
+ if search_results and hasattr(search_results, "answer") and search_results.answer:
470
+ context = f"Web search summary: {search_results.answer}\n\n"
471
+
472
+ for source in sources[:5]:
473
+ context += f"- {source['title']}: {source['snippet']}\n"
474
+
475
+ prompt = f"""You are a security researcher studying system internals.
476
+
477
+ Topic: {topic}
478
+
479
+ Research Context:
480
+ {context}
481
+
482
+ Based on this context, provide:
483
+ 1. A concise summary (2-3 sentences) of how this system/technology normally works
484
+ 2. 3-5 key findings about normal behavior
485
+
486
+ Return JSON format:
487
+ {{
488
+ "summary": "string",
489
+ "key_findings": ["finding1", "finding2", "finding3"]
490
+ }}"""
491
+
492
+ response = self._call_llm(prompt)
493
+ data = json.loads(response)
494
+ return data["summary"], data["key_findings"]
495
+
496
+ except Exception as e:
497
+ return f"System research for {topic} (LLM error: {str(e)[:50]})", ["Error during LLM analysis"]
498
+
499
+ def _llm_summarize_tradecraft(
500
+ self,
501
+ topic: str,
502
+ technique: Optional[str],
503
+ sources: List[Dict[str, str]],
504
+ search_results: Optional[Any],
505
+ ) -> tuple[str, List[str]]:
506
+ """Use LLM to summarize adversary tradecraft findings."""
507
+ try:
508
+ client = self._get_llm_client()
509
+ if not client:
510
+ return f"Adversary tradecraft for {topic}", ["LLM unavailable"]
511
+
512
+ # Build context from sources
513
+ context = ""
514
+ if search_results and hasattr(search_results, "answer") and search_results.answer:
515
+ context = f"Web search summary: {search_results.answer}\n\n"
516
+
517
+ for source in sources[:7]:
518
+ context += f"- {source['title']}: {source['snippet']}\n"
519
+
520
+ technique_str = f" ({technique})" if technique else ""
521
+
522
+ prompt = f"""You are a threat intelligence analyst studying adversary techniques.
523
+
524
+ Topic: {topic}{technique_str}
525
+
526
+ Research Context:
527
+ {context}
528
+
529
+ Based on this context, provide:
530
+ 1. A concise summary (2-3 sentences) of how adversaries abuse this system/technique
531
+ 2. 4-6 key findings about attack methods, tools used, and indicators
532
+
533
+ Return JSON format:
534
+ {{
535
+ "summary": "string",
536
+ "key_findings": ["finding1", "finding2", "finding3", "finding4"]
537
+ }}"""
538
+
539
+ response = self._call_llm(prompt)
540
+ data = json.loads(response)
541
+ return data["summary"], data["key_findings"]
542
+
543
+ except Exception as e:
544
+ return f"Adversary tradecraft for {topic} (LLM error: {str(e)[:50]})", ["Error during LLM analysis"]
545
+
546
+ def _llm_map_telemetry(
547
+ self,
548
+ topic: str,
549
+ technique: Optional[str],
550
+ ocsf_schema: str,
551
+ environment_data: str,
552
+ ) -> tuple[str, List[str]]:
553
+ """Use LLM to map topic to OCSF telemetry fields."""
554
+ try:
555
+ client = self._get_llm_client()
556
+ if not client:
557
+ return f"Telemetry mapping for {topic}", ["LLM unavailable"]
558
+
559
+ technique_str = f" ({technique})" if technique else ""
560
+
561
+ prompt = f"""You are a detection engineer mapping attack behaviors to telemetry.
562
+
563
+ Topic: {topic}{technique_str}
564
+
565
+ OCSF Schema Reference (partial):
566
+ {ocsf_schema[:3000]}
567
+
568
+ Environment:
569
+ {environment_data[:1000]}
570
+
571
+ Based on this context, provide:
572
+ 1. A concise summary of what telemetry would capture this behavior
573
+ 2. 4-6 specific OCSF fields that are relevant, with population rates if known
574
+
575
+ Return JSON format:
576
+ {{
577
+ "summary": "string",
578
+ "key_findings": ["field1 (X% populated): description", "field2: description"]
579
+ }}"""
580
+
581
+ response = self._call_llm(prompt)
582
+ data = json.loads(response)
583
+ return data["summary"], data["key_findings"]
584
+
585
+ except Exception as e:
586
+ return f"Telemetry mapping for {topic} (LLM error: {str(e)[:50]})", ["Error during LLM analysis"]
587
+
588
+ def _llm_synthesize(
589
+ self,
590
+ topic: str,
591
+ technique: Optional[str],
592
+ skills: List[ResearchSkillOutput],
593
+ ) -> tuple[str, List[str]]:
594
+ """Use LLM to synthesize all research findings."""
595
+ try:
596
+ client = self._get_llm_client()
597
+ if not client:
598
+ return f"Research synthesis for {topic}", ["LLM unavailable"]
599
+
600
+ # Build context from all skills
601
+ context = ""
602
+ for skill in skills:
603
+ context += f"\n### {skill.skill_name.replace('_', ' ').title()}\n"
604
+ context += f"Summary: {skill.summary}\n"
605
+ context += "Key findings:\n"
606
+ for finding in skill.key_findings[:4]:
607
+ context += f"- {finding}\n"
608
+
609
+ technique_str = f" ({technique})" if technique else ""
610
+
611
+ prompt = f"""You are a senior threat hunter synthesizing research for a hunt.
612
+
613
+ Topic: {topic}{technique_str}
614
+
615
+ Research Findings:
616
+ {context}
617
+
618
+ Based on all research findings, provide:
619
+ 1. An executive summary (2-3 sentences) synthesizing all findings
620
+ 2. A recommended hypothesis statement in the format: "Adversaries use [behavior] to [goal] on [target]"
621
+ 3. 2-3 gaps identified in current coverage or knowledge
622
+ 4. 2-3 recommended focus areas for the hunt
623
+
624
+ Return JSON format:
625
+ {{
626
+ "summary": "string",
627
+ "key_findings": [
628
+ "Hypothesis: Adversaries use...",
629
+ "Gap: ...",
630
+ "Focus: ..."
631
+ ]
632
+ }}"""
633
+
634
+ response = self._call_llm(prompt)
635
+ data = json.loads(response)
636
+ return data["summary"], data["key_findings"]
637
+
638
+ except Exception as e:
639
+ return f"Research synthesis for {topic} (LLM error: {str(e)[:50]})", ["Error during LLM analysis"]
640
+
641
+ def _call_llm(self, prompt: str) -> str:
642
+ """Call LLM and return response text."""
643
+ client = self._get_llm_client()
644
+ if not client:
645
+ raise ValueError("LLM client not available")
646
+
647
+ # Bedrock model ID - using cross-region inference profile for Claude Sonnet
648
+ model_id = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
649
+
650
+ # Prepare request body for Bedrock
651
+ request_body = {
652
+ "anthropic_version": "bedrock-2023-05-31",
653
+ "max_tokens": 2048,
654
+ "messages": [{"role": "user", "content": prompt}],
655
+ }
656
+
657
+ # Invoke model via Bedrock
658
+ start_time = time.time()
659
+ response = client.invoke_model(modelId=model_id, body=json.dumps(request_body))
660
+ duration_ms = int((time.time() - start_time) * 1000)
661
+
662
+ # Parse Bedrock response
663
+ response_body = json.loads(response["body"].read())
664
+
665
+ # Extract text from response
666
+ output_text: str = str(response_body["content"][0]["text"])
667
+
668
+ # Try to extract JSON from markdown code blocks if present
669
+ if "```json" in output_text:
670
+ json_start = output_text.find("```json") + 7
671
+ json_end = output_text.find("```", json_start)
672
+ output_text = output_text[json_start:json_end].strip()
673
+ elif "```" in output_text:
674
+ json_start = output_text.find("```") + 3
675
+ json_end = output_text.find("```", json_start)
676
+ output_text = output_text[json_start:json_end].strip()
677
+
678
+ # Track costs
679
+ usage = response_body.get("usage", {})
680
+ input_tokens = usage.get("input_tokens", 0)
681
+ output_tokens = usage.get("output_tokens", 0)
682
+ cost = self._calculate_cost_bedrock(input_tokens, output_tokens)
683
+ self._total_cost += cost
684
+ self._llm_calls += 1
685
+
686
+ # Log metrics
687
+ self._log_llm_metrics(
688
+ agent_name="hunt-researcher",
689
+ model_id=model_id,
690
+ input_tokens=input_tokens,
691
+ output_tokens=output_tokens,
692
+ cost_usd=cost,
693
+ duration_ms=duration_ms,
694
+ )
695
+
696
+ return output_text
697
+
698
+ def _calculate_cost_bedrock(self, input_tokens: int, output_tokens: int) -> float:
699
+ """Calculate AWS Bedrock Claude cost."""
700
+ # Claude Sonnet on Bedrock pricing
701
+ input_cost_per_1k = 0.003
702
+ output_cost_per_1k = 0.015
703
+
704
+ input_cost = (input_tokens / 1000) * input_cost_per_1k
705
+ output_cost = (output_tokens / 1000) * output_cost_per_1k
706
+
707
+ return round(input_cost + output_cost, 4)
708
+
709
+ def _load_ocsf_schema(self) -> str:
710
+ """Load OCSF schema reference content."""
711
+ schema_path = Path.cwd() / "knowledge" / "OCSF_SCHEMA_REFERENCE.md"
712
+ if schema_path.exists():
713
+ return schema_path.read_text()[:5000] # Limit size
714
+ return "OCSF schema reference not found"
715
+
716
+ def _load_environment(self) -> str:
717
+ """Load environment.md content."""
718
+ env_path = Path.cwd() / "environment.md"
719
+ if env_path.exists():
720
+ return env_path.read_text()[:2000] # Limit size
721
+ return "Environment file not found"
722
+
723
+ def _extract_hypothesis(self, synthesis: ResearchSkillOutput) -> Optional[str]:
724
+ """Extract recommended hypothesis from synthesis."""
725
+ for finding in synthesis.key_findings:
726
+ if finding.lower().startswith("hypothesis:"):
727
+ return finding.replace("Hypothesis:", "").replace("hypothesis:", "").strip()
728
+ return None
729
+
730
+ def _extract_data_sources(self, telemetry: ResearchSkillOutput) -> Dict[str, bool]:
731
+ """Extract data source availability from telemetry mapping."""
732
+ # Default data sources based on environment
733
+ return {
734
+ "process_execution": True,
735
+ "file_operations": True,
736
+ "network_connections": False, # Limited visibility per AGENTS.md
737
+ "registry_events": False, # Platform-dependent
738
+ }
739
+
740
+ def _estimate_complexity(
741
+ self,
742
+ tradecraft: ResearchSkillOutput,
743
+ telemetry: ResearchSkillOutput,
744
+ ) -> str:
745
+ """Estimate hunt complexity based on research."""
746
+ # Simple heuristic based on number of findings
747
+ total_findings = len(tradecraft.key_findings) + len(telemetry.key_findings)
748
+
749
+ if total_findings <= 4:
750
+ return "low"
751
+ elif total_findings <= 8:
752
+ return "medium"
753
+ else:
754
+ return "high"
755
+
756
+ def _extract_gaps(self, synthesis: ResearchSkillOutput) -> List[str]:
757
+ """Extract identified gaps from synthesis."""
758
+ gaps = []
759
+ for finding in synthesis.key_findings:
760
+ if finding.lower().startswith("gap:"):
761
+ gaps.append(finding.replace("Gap:", "").replace("gap:", "").strip())
762
+ return gaps