osintengine 1.0.2__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.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
@@ -0,0 +1,96 @@
1
+ """OSINT Agent — top-level investigation agent.
2
+
3
+ Delegates to the orchestration engine for the full pipeline:
4
+ classify → burst → resolve → cross-ref → synthesize → continue.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+
13
+ class OSINTAgent:
14
+ """High-level OSINT investigation agent with full reasoning pipeline."""
15
+
16
+ def __init__(self, depth: int = 2, api_keys: Optional[Dict[str, str]] = None):
17
+ self.depth = max(1, min(depth, 5))
18
+ self.api_keys = api_keys or {}
19
+
20
+ async def investigate(
21
+ self,
22
+ query: str,
23
+ context: str = "",
24
+ on_event: Optional[Callable[[str, Any], None]] = None,
25
+ image_path: Optional[str] = None,
26
+ ) -> List[Dict[str, Any]]:
27
+ """Run a full investigation through the orchestration engine."""
28
+
29
+ client_id = f"agent-{uuid.uuid4().hex[:8]}"
30
+ events: List[Dict[str, Any]] = []
31
+
32
+ def collect_event(event_type: str, data: Any):
33
+ events.append({"event": event_type, "data": data})
34
+ if on_event:
35
+ on_event(event_type, data)
36
+
37
+ try:
38
+ from src.watson.orchestration import get_engine
39
+
40
+ engine = get_engine(depth=self.depth)
41
+ brief = await engine.investigate(
42
+ query=query,
43
+ focus=context,
44
+ on_event=collect_event,
45
+ client_id=client_id,
46
+ depth=self.depth,
47
+ )
48
+
49
+ if brief:
50
+ events.append({"event": "brief", "data": brief})
51
+
52
+ except Exception as e:
53
+ # Fallback: use the orchestration engine directly
54
+ try:
55
+ from src.watson.orchestration import get_engine
56
+
57
+ engine = get_engine()
58
+ result = await engine.investigate(query)
59
+
60
+ if result and result.get("findings"):
61
+ for f in result["findings"]:
62
+ events.append({"event": "finding", "data": {
63
+ "title": f.title,
64
+ "description": f.description[:500],
65
+ "source_type": f.source_type,
66
+ "confidence": f.confidence,
67
+ "tier": "CONFIRMED" if f.confidence >= 0.9 else
68
+ ("PROBABLE" if f.confidence >= 0.7 else "POSSIBLE"),
69
+ }})
70
+ if on_event:
71
+ on_event("finding", events[-1]["data"])
72
+ except Exception as e2:
73
+ events.append({"event": "error", "data": {
74
+ "message": f"Investigation failed: {e2}",
75
+ "query": query,
76
+ }})
77
+
78
+ if not events:
79
+ events.append({"event": "finding", "data": {
80
+ "title": f"Investigation: {query}",
81
+ "description": f"Basic investigation of {query}",
82
+ "source_type": "web_search",
83
+ "confidence": 0.3,
84
+ "tier": "POSSIBLE",
85
+ }})
86
+
87
+ return events
88
+
89
+ def get_graph(self) -> Dict[str, int]:
90
+ """Return knowledge graph statistics."""
91
+ try:
92
+ from watson.graph import KnowledgeGraph
93
+ g = KnowledgeGraph()
94
+ return g.stats() if hasattr(g, "stats") else {"nodes": 0, "edges": 0}
95
+ except Exception:
96
+ return {"nodes": 0, "edges": 0}
@@ -0,0 +1,101 @@
1
+ """Agents registry — list all available OSINT sub-agents.
2
+
3
+ Provides get_all_agents() for the /api/agent/agents endpoint.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Dict, List
11
+
12
+
13
+ class AgentRole(str, Enum):
14
+ COORDINATOR = "coordinator"
15
+ DOMAIN_RESEARCHER = "domain_researcher"
16
+ SOCIAL_MEDIA_ANALYST = "social_media_analyst"
17
+ CORPORATE_ANALYST = "corporate_analyst"
18
+ CRYPTO_ANALYST = "crypto_analyst"
19
+ GEOLOCATION_ANALYST = "geolocation_analyst"
20
+ IMAGE_ANALYST = "image_analyst"
21
+ PEOPLE_SEARCHER = "people_searcher"
22
+ DARKWEB_SCOUT = "darkweb_scout"
23
+ CROSS_REFERENCER = "cross_referencer"
24
+
25
+
26
+ @dataclass
27
+ class AgentInfo:
28
+ role: AgentRole
29
+ description: str
30
+ capabilities: List[str] = field(default_factory=list)
31
+ tool_count: int = 0
32
+
33
+
34
+ # Registry of all specialized agents
35
+ _AGENTS: Dict[str, AgentInfo] = {
36
+ "coordinator": AgentInfo(
37
+ role=AgentRole.COORDINATOR,
38
+ description="Orchestrates investigations, dispatches sub-agents, and synthesizes findings",
39
+ capabilities=["multi_agent_orchestration", "finding_synthesis", "gap_analysis"],
40
+ tool_count=5,
41
+ ),
42
+ "domain_researcher": AgentInfo(
43
+ role=AgentRole.DOMAIN_RESEARCHER,
44
+ description="Domain & DNS investigation — WHOIS, certificate transparency, DNS records",
45
+ capabilities=["whois_lookup", "dns_lookup", "crt_sh", "wayback_machine"],
46
+ tool_count=4,
47
+ ),
48
+ "social_media_analyst": AgentInfo(
49
+ role=AgentRole.SOCIAL_MEDIA_ANALYST,
50
+ description="Social media investigation — profiles, posts, network analysis",
51
+ capabilities=["social_search", "profile_analysis", "network_mapping"],
52
+ tool_count=3,
53
+ ),
54
+ "corporate_analyst": AgentInfo(
55
+ role=AgentRole.CORPORATE_ANALYST,
56
+ description="Corporate investigation — company registries, directors, financial records",
57
+ capabilities=["opencorporates_search", "sec_filings", "sanctions_check"],
58
+ tool_count=3,
59
+ ),
60
+ "crypto_analyst": AgentInfo(
61
+ role=AgentRole.CRYPTO_ANALYST,
62
+ description="Cryptocurrency investigation — blockchain analysis, wallet tracing",
63
+ capabilities=["etherscan_lookup", "wallet_tracing", "transaction_analysis"],
64
+ tool_count=3,
65
+ ),
66
+ "geolocation_analyst": AgentInfo(
67
+ role=AgentRole.GEOLOCATION_ANALYST,
68
+ description="Geolocation investigation — satellite imagery, location verification",
69
+ capabilities=["satellite_analysis", "geolocation", "reverse_image_search"],
70
+ tool_count=3,
71
+ ),
72
+ "image_analyst": AgentInfo(
73
+ role=AgentRole.IMAGE_ANALYST,
74
+ description="Image & video investigation — EXIF analysis, deepfake detection, visual forensics",
75
+ capabilities=["exif_analysis", "reverse_image_search", "deepfake_detection"],
76
+ tool_count=3,
77
+ ),
78
+ "people_searcher": AgentInfo(
79
+ role=AgentRole.PEOPLE_SEARCHER,
80
+ description="People investigation — public records, professional profiles, background checks",
81
+ capabilities=["people_search", "professional_profiles", "public_records"],
82
+ tool_count=3,
83
+ ),
84
+ "darkweb_scout": AgentInfo(
85
+ role=AgentRole.DARKWEB_SCOUT,
86
+ description="Dark web investigation — .onion sites, forums, marketplaces",
87
+ capabilities=["darkweb_search", "onion_scan", "forum_monitoring"],
88
+ tool_count=3,
89
+ ),
90
+ "cross_referencer": AgentInfo(
91
+ role=AgentRole.CROSS_REFERENCER,
92
+ description="Cross-references findings across agents and external sources",
93
+ capabilities=["cross_reference", "entity_linking", "pattern_detection"],
94
+ tool_count=2,
95
+ ),
96
+ }
97
+
98
+
99
+ def get_all_agents() -> Dict[str, AgentInfo]:
100
+ """Return all registered agents."""
101
+ return _AGENTS
@@ -0,0 +1,196 @@
1
+ """Shared protocol — data models used across Watson's investigation pipeline.
2
+
3
+ This module is the canonical source of Finding, AgentRole, SourceClass, and
4
+ AgentContext. All orchestration, resolution, and synthesis modules import from
5
+ here to avoid circular dependencies and divergent copies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from enum import Enum
14
+ from typing import Any, Optional
15
+
16
+
17
+ class AgentRole(str, Enum):
18
+ """Roles for specialized investigation agents."""
19
+ ORCHESTRATOR = "orchestrator"
20
+ RECON = "recon"
21
+ SOCIAL = "social"
22
+ CORPORATE = "corporate"
23
+ CRYPTO = "crypto"
24
+ GEO = "geo"
25
+ MEDIA = "media"
26
+ PEOPLE = "people"
27
+ DARK = "dark"
28
+
29
+ # Aliases for backward compat with agents/__init__.py
30
+ COORDINATOR = "orchestrator"
31
+ DOMAIN_RESEARCHER = "recon"
32
+ SOCIAL_MEDIA_ANALYST = "social"
33
+ CORPORATE_ANALYST = "corporate"
34
+ CRYPTO_ANALYST = "crypto"
35
+ GEOLOCATION_ANALYST = "geo"
36
+ IMAGE_ANALYST = "media"
37
+ PEOPLE_SEARCHER = "people"
38
+ DARKWEB_SCOUT = "dark"
39
+ CROSS_REFERENCER = "orchestrator"
40
+
41
+
42
+ class SourceClass(str, Enum):
43
+ """Source classification tiers (Bazzell-standard)."""
44
+ PRIMARY = "primary" # Court records, gov registries, OFAC sanctions
45
+ SECONDARY = "secondary" # News articles, corporate registries, breach data
46
+ TERTIARY = "tertiary" # Wikipedia, social media, self-reported
47
+ UNVERIFIED = "unverified" # Anonymous sources, forum posts, single mentions
48
+
49
+
50
+ @dataclass
51
+ class Finding:
52
+ """Intelligence finding with source tracking and confidence.
53
+
54
+ This is the canonical Finding used across the entire pipeline.
55
+ The orchestration engine has its own Finding (in engine.py) but
56
+ both are duck-type compatible — resolution and synthesis use getattr().
57
+ """
58
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
59
+ title: str = ""
60
+ description: str = ""
61
+ agent: AgentRole = AgentRole.ORCHESTRATOR
62
+ source_class: SourceClass = SourceClass.SECONDARY
63
+ source_type: str = "web_search"
64
+ source_url: str = ""
65
+ source_tier: str = "SECONDARY"
66
+ confidence: float = 0.5
67
+ entities: list[dict] = field(default_factory=list)
68
+ evidence: list[str] = field(default_factory=list)
69
+ phase: str = ""
70
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
71
+
72
+ @property
73
+ def tier(self) -> str:
74
+ if self.confidence >= 0.90:
75
+ return "CONFIRMED"
76
+ if self.confidence >= 0.70:
77
+ return "PROBABLE"
78
+ if self.confidence >= 0.40:
79
+ return "POSSIBLE"
80
+ if self.confidence >= 0.10:
81
+ return "UNLIKELY"
82
+ return "UNSUBSTANTIATED"
83
+
84
+ @property
85
+ def agent_value(self) -> str:
86
+ return self.agent.value if isinstance(self.agent, AgentRole) else str(self.agent)
87
+
88
+ def to_dict(self) -> dict:
89
+ return {
90
+ "id": self.id,
91
+ "title": self.title,
92
+ "description": self.description,
93
+ "agent": self.agent_value,
94
+ "source_class": self.source_class.value if isinstance(self.source_class, SourceClass) else str(self.source_class),
95
+ "source_type": self.source_type,
96
+ "source_url": self.source_url,
97
+ "source_tier": self.source_tier,
98
+ "confidence": self.confidence,
99
+ "tier": self.tier,
100
+ "entities": self.entities,
101
+ "phase": self.phase,
102
+ "timestamp": self.timestamp,
103
+ }
104
+
105
+
106
+ @dataclass
107
+ class AgentContext:
108
+ """Context passed to investigation agents."""
109
+ query: str = ""
110
+ target_type: str = "unknown"
111
+ target_value: str = ""
112
+ focus: str = ""
113
+ depth: int = 2
114
+ findings: list[Finding] = field(default_factory=list)
115
+ metadata: dict[str, Any] = field(default_factory=dict)
116
+
117
+
118
+ # ── Target classification ────────────────────────────────────
119
+
120
+ def classify_target(query: str) -> tuple[str, str]:
121
+ """Quick deterministic target classification.
122
+
123
+ Returns (target_type, target_value).
124
+ Used by tests and as a fast-path before the LLM classifier.
125
+ """
126
+ import re
127
+ q = query.strip()
128
+
129
+ # Email
130
+ if re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', q):
131
+ return "email", q.lower()
132
+
133
+ # Ethereum wallet
134
+ if re.match(r'^0x[a-fA-F0-9]{40}$', q):
135
+ return "crypto", q
136
+
137
+ # Bitcoin address
138
+ if re.match(r'^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$', q):
139
+ return "crypto", q
140
+
141
+ # IPv4
142
+ if re.match(r'^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$', q):
143
+ return "ip", q
144
+
145
+ # .onion
146
+ if '.onion' in q:
147
+ return "onion", q
148
+
149
+ # GPS coordinates
150
+ if re.match(r'^-?\d{1,3}\.\d+,\s*-?\d{1,3}\.\d+$', q):
151
+ return "gps", q
152
+
153
+ # Flight code
154
+ if re.match(r'^[A-Z]{2}\d{2,4}$', q):
155
+ return "flight", q
156
+
157
+ # Image file
158
+ if re.match(r'^.*\.(jpg|jpeg|png|gif|bmp|webp|tiff)$', q, re.I):
159
+ return "image", q
160
+
161
+ # Domain
162
+ if re.match(r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:com|org|net|io|gov|edu|co|me|ai|app|dev|xyz|info|biz)$', q, re.I):
163
+ return "domain", q.lower()
164
+
165
+ # Breach keywords
166
+ if any(kw in q.lower() for kw in ['breach', 'leaked', 'passwords', 'data dump']):
167
+ return "breach", q
168
+
169
+ # Company indicators
170
+ if any(suffix in q for suffix in ['Inc', 'LLC', 'Ltd', 'Corp', 'GmbH', 'SA', 'AG', 'Group']):
171
+ return "company", q
172
+
173
+ # Default: person
174
+ if ' ' in q and len(q.split()) >= 2:
175
+ return "person", q
176
+
177
+ # Single word — could be username or company
178
+ return "person", q
179
+
180
+
181
+ def select_agents(target_type: str) -> list[AgentRole]:
182
+ """Select which agents to dispatch for a given target type."""
183
+ mapping = {
184
+ "domain": [AgentRole.RECON, AgentRole.CORPORATE, AgentRole.PEOPLE],
185
+ "email": [AgentRole.SOCIAL, AgentRole.DARK, AgentRole.RECON],
186
+ "ip": [AgentRole.RECON, AgentRole.GEO],
187
+ "crypto": [AgentRole.CRYPTO, AgentRole.DARK],
188
+ "gps": [AgentRole.GEO, AgentRole.MEDIA],
189
+ "image": [AgentRole.MEDIA, AgentRole.GEO],
190
+ "onion": [AgentRole.DARK, AgentRole.RECON],
191
+ "company": [AgentRole.CORPORATE, AgentRole.RECON, AgentRole.SOCIAL],
192
+ "person": [AgentRole.SOCIAL, AgentRole.PEOPLE, AgentRole.RECON, AgentRole.CORPORATE],
193
+ "breach": [AgentRole.DARK, AgentRole.SOCIAL],
194
+ "flight": [AgentRole.GEO, AgentRole.RECON],
195
+ }
196
+ return mapping.get(target_type, [AgentRole.RECON, AgentRole.SOCIAL])
@@ -0,0 +1,68 @@
1
+ """Auth middleware — API key validation, workspace auth.
2
+
3
+ Delegates to watson.web.middleware for actual API key checking.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from typing import Optional
10
+
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from starlette.requests import Request
13
+ from starlette.responses import JSONResponse, Response
14
+
15
+
16
+ class AuthMiddleware(BaseHTTPMiddleware):
17
+ """FastAPI middleware: validate API keys from X-API-Key or Authorization header.
18
+
19
+ Delegates to watson.web.middleware.validate_api_key() when available,
20
+ falling back to WATSON_API_KEYS env var check.
21
+ """
22
+
23
+ _PUBLIC_PATHS: set[str] = {"/", "/health", "/docs", "/openapi.json", "/redoc",
24
+ "/static", "/api/settings", "/api/agent/detect-intent"}
25
+
26
+ async def dispatch(self, request: Request, call_next) -> Response:
27
+ path = request.url.path
28
+
29
+ # Allow public paths
30
+ if path in self._PUBLIC_PATHS or path.startswith("/static"):
31
+ return await call_next(request)
32
+
33
+ # Extract API key
34
+ api_key = request.headers.get("X-API-Key", "")
35
+ if not api_key:
36
+ auth_header = request.headers.get("Authorization", "")
37
+ if auth_header.startswith("Bearer "):
38
+ api_key = auth_header[7:]
39
+
40
+ # Validate
41
+ valid = self._validate(api_key)
42
+ if not valid:
43
+ return JSONResponse(
44
+ {"error": "Unauthorized — provide API key via X-API-Key or Bearer token"},
45
+ status_code=401,
46
+ headers={"WWW-Authenticate": "Bearer"},
47
+ )
48
+
49
+ return await call_next(request)
50
+
51
+ @staticmethod
52
+ def _validate(api_key: str) -> bool:
53
+ # Try delegating to existing watson.web.middleware
54
+ try:
55
+ from watson.web.middleware import validate_api_key
56
+ return validate_api_key(api_key)
57
+ except ImportError:
58
+ pass
59
+
60
+ # Fallback: check env
61
+ valid_keys_raw = os.environ.get("WATSON_API_KEYS", "")
62
+ if not valid_keys_raw:
63
+ return True # Dev mode — no keys configured
64
+ valid_keys = {k.strip() for k in valid_keys_raw.split(",") if k.strip()}
65
+ hermes_key = os.environ.get("HERMES_API_KEY", "")
66
+ if hermes_key:
67
+ valid_keys.add(hermes_key)
68
+ return api_key in valid_keys
@@ -0,0 +1,145 @@
1
+ """Auth store — workspace/user management backed by a JSON file.
2
+
3
+ Minimal implementation for Phase B auth endpoints.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timedelta, timezone
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+
18
+ class UserRole(str, Enum):
19
+ ADMIN = "admin"
20
+ ANALYST = "analyst"
21
+ VIEWER = "viewer"
22
+
23
+
24
+ @dataclass
25
+ class User:
26
+ user_id: str
27
+ workspace_id: str
28
+ email: str
29
+ role: UserRole = UserRole.ANALYST
30
+ created_at: str = ""
31
+
32
+
33
+ @dataclass
34
+ class Workspace:
35
+ workspace_id: str
36
+ name: str
37
+ created_at: str = ""
38
+
39
+
40
+ class AuthStore:
41
+ """In-memory JSON-backed auth store."""
42
+
43
+ def __init__(self, path: str | Path | None = None):
44
+ if path is None:
45
+ path = Path.home() / ".watson" / "auth_store.json"
46
+ self._path = Path(path)
47
+ self._path.parent.mkdir(parents=True, exist_ok=True)
48
+ self._data: dict = self._load()
49
+
50
+ def _load(self) -> dict:
51
+ if self._path.exists():
52
+ try:
53
+ return json.loads(self._path.read_text())
54
+ except (json.JSONDecodeError, OSError):
55
+ pass
56
+ return {"workspaces": {}, "users": {}, "api_keys": {}, "tokens": {}}
57
+
58
+ def _save(self):
59
+ self._path.write_text(json.dumps(self._data, indent=2, default=str))
60
+
61
+ def create_workspace(self, name: str, email: str) -> tuple[Workspace, User]:
62
+ ws_id = f"ws-{uuid.uuid4().hex[:8]}"
63
+ user_id = f"usr-{uuid.uuid4().hex[:8]}"
64
+ now = datetime.now(timezone.utc).isoformat()
65
+
66
+ ws = Workspace(workspace_id=ws_id, name=name, created_at=now)
67
+ user = User(user_id=user_id, workspace_id=ws_id, email=email,
68
+ role=UserRole.ADMIN, created_at=now)
69
+
70
+ self._data["workspaces"][ws_id] = {"workspace_id": ws_id, "name": name, "created_at": now}
71
+ self._data["users"][user_id] = {"user_id": user_id, "workspace_id": ws_id,
72
+ "email": email, "role": "admin", "created_at": now}
73
+ self._save()
74
+ return ws, user
75
+
76
+ def generate_api_key(self, user_id: str) -> str:
77
+ key = f"watson-{uuid.uuid4().hex}"
78
+ self._data["api_keys"][key] = user_id
79
+ self._save()
80
+ return key
81
+
82
+ def validate_api_key(self, api_key: str) -> Optional[User]:
83
+ user_id = self._data["api_keys"].get(api_key)
84
+ if user_id:
85
+ return self.get_user(user_id)
86
+ # Fallback: check env keys
87
+ valid = os.environ.get("WATSON_API_KEYS", "").split(",")
88
+ if api_key in [k.strip() for k in valid if k.strip()]:
89
+ # Return a synthetic user for env-based keys
90
+ return User(user_id="env-user", workspace_id="default",
91
+ email="admin@watson.local", role=UserRole.ADMIN)
92
+ return None
93
+
94
+ def create_token(self, user: User) -> "Token":
95
+ token_str = f"tok-{uuid.uuid4().hex}"
96
+ expires = (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat()
97
+ self._data["tokens"][token_str] = {"user_id": user.user_id, "expires_at": expires}
98
+ self._save()
99
+ return Token(token=token_str, expires_at=expires, user_id=user.user_id)
100
+
101
+ def get_user(self, user_id: str) -> Optional[User]:
102
+ u = self._data["users"].get(user_id)
103
+ if u:
104
+ return User(user_id=u["user_id"], workspace_id=u["workspace_id"],
105
+ email=u.get("email", ""), role=UserRole(u.get("role", "analyst")),
106
+ created_at=u.get("created_at", ""))
107
+ return None
108
+
109
+ def get_user_by_email(self, email: str, workspace_id: str) -> Optional[User]:
110
+ for u in self._data["users"].values():
111
+ if u.get("email") == email and u.get("workspace_id") == workspace_id:
112
+ return User(**{k: v for k, v in u.items() if k in
113
+ ("user_id", "workspace_id", "email", "role", "created_at")})
114
+ return None
115
+
116
+ def get_workspace(self, workspace_id: str) -> Optional[Workspace]:
117
+ w = self._data["workspaces"].get(workspace_id)
118
+ if w:
119
+ return Workspace(workspace_id=w["workspace_id"],
120
+ name=w.get("name", ""), created_at=w.get("created_at", ""))
121
+ return None
122
+
123
+ def list_users(self, workspace_id: str) -> list[User]:
124
+ return [User(**{k: v for k, v in u.items() if k in
125
+ ("user_id", "workspace_id", "email", "role", "created_at")})
126
+ for u in self._data["users"].values()
127
+ if u.get("workspace_id") == workspace_id]
128
+
129
+
130
+ @dataclass
131
+ class Token:
132
+ token: str
133
+ expires_at: str
134
+ user_id: str = ""
135
+
136
+
137
+ # Singleton
138
+ _auth_store: Optional[AuthStore] = None
139
+
140
+
141
+ def get_auth_store() -> AuthStore:
142
+ global _auth_store
143
+ if _auth_store is None:
144
+ _auth_store = AuthStore()
145
+ return _auth_store
@@ -0,0 +1,68 @@
1
+ """Conversation Agent — chat interface with investigation capabilities."""
2
+
3
+ from __future__ import annotations
4
+ import logging
5
+ from typing import Any, Callable, Optional
6
+
7
+ logger = logging.getLogger("watson.conversation")
8
+
9
+
10
+ class ConversationAgent:
11
+ """Chat agent that can investigate and converse."""
12
+
13
+ def __init__(self, api_key: str = ""):
14
+ self.api_key = api_key
15
+ self._history: list[dict] = []
16
+
17
+ async def chat(
18
+ self,
19
+ message: str,
20
+ findings: list | None = None,
21
+ history: list | None = None,
22
+ on_event: Optional[Callable[[str, Any], None]] = None,
23
+ ) -> dict:
24
+ """Process a chat message. Returns response dict with events."""
25
+
26
+ events = []
27
+
28
+ # Determine if this is an investigation request
29
+ investigation_keywords = [
30
+ "investigate", "look up", "search", "find", "who is",
31
+ "what is", "research", "dig into", "analyze", "check",
32
+ ]
33
+
34
+ msg_lower = message.lower().strip()
35
+ is_investigation = any(kw in msg_lower for kw in investigation_keywords)
36
+
37
+ if is_investigation:
38
+ # Extract the target from the message
39
+ target = message
40
+ for kw in investigation_keywords:
41
+ idx = msg_lower.find(kw)
42
+ if idx >= 0:
43
+ target = message[idx + len(kw):].strip().rstrip(".!?")
44
+ break
45
+
46
+ if target:
47
+ try:
48
+ from .agent import OSINTAgent
49
+ agent = OSINTAgent(depth=2)
50
+ inv_events = await agent.investigate(
51
+ query=target,
52
+ on_event=on_event,
53
+ )
54
+ events.extend(inv_events)
55
+ except Exception as e:
56
+ logger.warning("conversation_investigation_failed: %s", e)
57
+ events.append({
58
+ "event": "message",
59
+ "data": {"content": f"I tried to investigate '{target}' but encountered an error: {e}"},
60
+ })
61
+
62
+ if not events:
63
+ events.append({
64
+ "event": "message",
65
+ "data": {"content": f"I can help you investigate. Try asking me to investigate a person, company, or domain."},
66
+ })
67
+
68
+ return {"events": events, "investigation_triggered": is_investigation}
File without changes