outcome-cli 1.0.0
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.
- package/README.md +261 -0
- package/package.json +95 -0
- package/src/agents/README.md +139 -0
- package/src/agents/adapters/anthropic.adapter.ts +166 -0
- package/src/agents/adapters/dalle.adapter.ts +145 -0
- package/src/agents/adapters/gemini.adapter.ts +134 -0
- package/src/agents/adapters/imagen.adapter.ts +106 -0
- package/src/agents/adapters/nano-banana.adapter.ts +129 -0
- package/src/agents/adapters/openai.adapter.ts +165 -0
- package/src/agents/adapters/veo.adapter.ts +130 -0
- package/src/agents/agent.schema.property.test.ts +379 -0
- package/src/agents/agent.schema.test.ts +148 -0
- package/src/agents/agent.schema.ts +263 -0
- package/src/agents/index.ts +60 -0
- package/src/agents/registered-agent.schema.ts +356 -0
- package/src/agents/registry.ts +97 -0
- package/src/agents/tournament-configs.property.test.ts +266 -0
- package/src/cli/README.md +145 -0
- package/src/cli/commands/define.ts +79 -0
- package/src/cli/commands/list.ts +46 -0
- package/src/cli/commands/logs.ts +83 -0
- package/src/cli/commands/run.ts +416 -0
- package/src/cli/commands/verify.ts +110 -0
- package/src/cli/index.ts +81 -0
- package/src/config/README.md +128 -0
- package/src/config/env.ts +262 -0
- package/src/config/index.ts +19 -0
- package/src/eval/README.md +318 -0
- package/src/eval/ai-judge.test.ts +435 -0
- package/src/eval/ai-judge.ts +368 -0
- package/src/eval/code-validators.ts +414 -0
- package/src/eval/evaluateOutcome.property.test.ts +1174 -0
- package/src/eval/evaluateOutcome.ts +591 -0
- package/src/eval/immigration-validators.ts +122 -0
- package/src/eval/index.ts +90 -0
- package/src/eval/judge-cache.ts +402 -0
- package/src/eval/tournament-validators.property.test.ts +439 -0
- package/src/eval/validators.property.test.ts +1118 -0
- package/src/eval/validators.ts +1199 -0
- package/src/eval/weighted-scorer.ts +285 -0
- package/src/index.ts +17 -0
- package/src/league/README.md +188 -0
- package/src/league/health-check.ts +353 -0
- package/src/league/index.ts +93 -0
- package/src/league/killAgent.ts +151 -0
- package/src/league/league.test.ts +1151 -0
- package/src/league/runLeague.ts +843 -0
- package/src/league/scoreAgent.ts +175 -0
- package/src/modules/omnibridge/__tests__/.gitkeep +1 -0
- package/src/modules/omnibridge/__tests__/auth-tunnel.property.test.ts +524 -0
- package/src/modules/omnibridge/__tests__/deterministic-logger.property.test.ts +965 -0
- package/src/modules/omnibridge/__tests__/ghost-api.property.test.ts +461 -0
- package/src/modules/omnibridge/__tests__/omnibridge-integration.test.ts +542 -0
- package/src/modules/omnibridge/__tests__/parallel-executor.property.test.ts +671 -0
- package/src/modules/omnibridge/__tests__/semantic-normalizer.property.test.ts +521 -0
- package/src/modules/omnibridge/__tests__/semantic-normalizer.test.ts +254 -0
- package/src/modules/omnibridge/__tests__/session-vault.property.test.ts +367 -0
- package/src/modules/omnibridge/__tests__/shadow-session.property.test.ts +523 -0
- package/src/modules/omnibridge/__tests__/triangulation-engine.property.test.ts +292 -0
- package/src/modules/omnibridge/__tests__/verification-engine.property.test.ts +769 -0
- package/src/modules/omnibridge/api/.gitkeep +1 -0
- package/src/modules/omnibridge/api/ghost-api.ts +1087 -0
- package/src/modules/omnibridge/auth/.gitkeep +1 -0
- package/src/modules/omnibridge/auth/auth-tunnel.ts +843 -0
- package/src/modules/omnibridge/auth/session-vault.ts +577 -0
- package/src/modules/omnibridge/core/.gitkeep +1 -0
- package/src/modules/omnibridge/core/semantic-normalizer.ts +702 -0
- package/src/modules/omnibridge/core/triangulation-engine.ts +530 -0
- package/src/modules/omnibridge/core/types.ts +610 -0
- package/src/modules/omnibridge/execution/.gitkeep +1 -0
- package/src/modules/omnibridge/execution/deterministic-logger.ts +629 -0
- package/src/modules/omnibridge/execution/parallel-executor.ts +542 -0
- package/src/modules/omnibridge/execution/shadow-session.ts +794 -0
- package/src/modules/omnibridge/index.ts +212 -0
- package/src/modules/omnibridge/omnibridge.ts +510 -0
- package/src/modules/omnibridge/verification/.gitkeep +1 -0
- package/src/modules/omnibridge/verification/verification-engine.ts +783 -0
- package/src/outcomes/README.md +75 -0
- package/src/outcomes/acquire-pilot-customer.ts +297 -0
- package/src/outcomes/code-delivery-outcomes.ts +89 -0
- package/src/outcomes/code-outcomes.ts +256 -0
- package/src/outcomes/code_review_battle.test.ts +135 -0
- package/src/outcomes/code_review_battle.ts +135 -0
- package/src/outcomes/cold_email_battle.ts +97 -0
- package/src/outcomes/content_creation_battle.ts +160 -0
- package/src/outcomes/f1_stem_opt_compliance.ts +61 -0
- package/src/outcomes/index.ts +107 -0
- package/src/outcomes/lead_gen_battle.test.ts +113 -0
- package/src/outcomes/lead_gen_battle.ts +99 -0
- package/src/outcomes/outcome.schema.property.test.ts +229 -0
- package/src/outcomes/outcome.schema.ts +187 -0
- package/src/outcomes/qualified_sales_interest.ts +118 -0
- package/src/outcomes/swarm_planner.property.test.ts +370 -0
- package/src/outcomes/swarm_planner.ts +96 -0
- package/src/outcomes/web_extraction.ts +234 -0
- package/src/runtime/README.md +220 -0
- package/src/runtime/agentRunner.test.ts +341 -0
- package/src/runtime/agentRunner.ts +746 -0
- package/src/runtime/claudeAdapter.ts +232 -0
- package/src/runtime/costTracker.ts +123 -0
- package/src/runtime/index.ts +34 -0
- package/src/runtime/modelAdapter.property.test.ts +305 -0
- package/src/runtime/modelAdapter.ts +144 -0
- package/src/runtime/openaiAdapter.ts +235 -0
- package/src/utils/README.md +122 -0
- package/src/utils/command-runner.ts +134 -0
- package/src/utils/cost-guard.ts +379 -0
- package/src/utils/errors.test.ts +290 -0
- package/src/utils/errors.ts +442 -0
- package/src/utils/index.ts +37 -0
- package/src/utils/logger.test.ts +361 -0
- package/src/utils/logger.ts +419 -0
- package/src/utils/output-parsers.ts +216 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Tunnel - MFA Relay for OmniBridge
|
|
3
|
+
*
|
|
4
|
+
* Secure relay for human-in-the-loop authentication.
|
|
5
|
+
* Handles MFA challenge detection, user notification, and code injection.
|
|
6
|
+
*
|
|
7
|
+
* Requirements: 5.1, 5.2, 5.3, 5.4, 5.6
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from 'crypto';
|
|
11
|
+
import type {
|
|
12
|
+
MFAChallenge,
|
|
13
|
+
AuthResult,
|
|
14
|
+
AuthError,
|
|
15
|
+
ShadowSession,
|
|
16
|
+
IntentDocument,
|
|
17
|
+
IntentElement,
|
|
18
|
+
} from '../core/types.js';
|
|
19
|
+
import type { SessionVault, RawSessionData } from './session-vault.js';
|
|
20
|
+
import type { ShadowSessionOrchestrator } from '../execution/shadow-session.js';
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Constants
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
/** MFA timeout in milliseconds (5 minutes as per Requirements 5.6) */
|
|
27
|
+
export const MFA_TIMEOUT_MS = 300000; // 5 minutes = 300 seconds
|
|
28
|
+
|
|
29
|
+
/** MFA challenge polling interval */
|
|
30
|
+
export const MFA_POLL_INTERVAL_MS = 1000;
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Types
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* MFA detection patterns for different challenge types.
|
|
38
|
+
*/
|
|
39
|
+
export interface MFADetectionPattern {
|
|
40
|
+
/** Type of MFA challenge */
|
|
41
|
+
type: MFAChallenge['type'];
|
|
42
|
+
/** Keywords to look for in page content */
|
|
43
|
+
keywords: string[];
|
|
44
|
+
/** Input field patterns (type, name, placeholder) */
|
|
45
|
+
inputPatterns: RegExp[];
|
|
46
|
+
/** ARIA roles that indicate MFA */
|
|
47
|
+
ariaRoles: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* User notification configuration.
|
|
52
|
+
*/
|
|
53
|
+
export interface NotificationConfig {
|
|
54
|
+
/** Webhook URL for notifications */
|
|
55
|
+
webhookUrl?: string;
|
|
56
|
+
/** Dashboard notification callback */
|
|
57
|
+
dashboardCallback?: (challenge: MFAChallenge) => Promise<void>;
|
|
58
|
+
/** Email notification address */
|
|
59
|
+
notificationEmail?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* MFA code submission result.
|
|
64
|
+
*/
|
|
65
|
+
export interface MFASubmissionResult {
|
|
66
|
+
success: boolean;
|
|
67
|
+
sessionState?: RawSessionData;
|
|
68
|
+
error?: AuthError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pending MFA challenge with additional metadata.
|
|
73
|
+
*/
|
|
74
|
+
export interface PendingMFAChallenge extends MFAChallenge {
|
|
75
|
+
/** Callback to resolve the challenge */
|
|
76
|
+
resolve?: (code: string) => void;
|
|
77
|
+
/** Callback to reject the challenge */
|
|
78
|
+
reject?: (error: AuthError) => void;
|
|
79
|
+
/** Whether the challenge has been notified to user */
|
|
80
|
+
notified: boolean;
|
|
81
|
+
/** Number of code submission attempts */
|
|
82
|
+
attempts: number;
|
|
83
|
+
/** Maximum allowed attempts */
|
|
84
|
+
maxAttempts: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Auth Tunnel configuration.
|
|
89
|
+
*/
|
|
90
|
+
export interface AuthTunnelConfig {
|
|
91
|
+
/** Session Vault for storing authenticated states */
|
|
92
|
+
vault?: SessionVault;
|
|
93
|
+
/** Shadow Session Orchestrator for browser sessions */
|
|
94
|
+
orchestrator?: ShadowSessionOrchestrator;
|
|
95
|
+
/** Notification configuration */
|
|
96
|
+
notificationConfig?: NotificationConfig;
|
|
97
|
+
/** MFA timeout in milliseconds (default: 300000 = 5 minutes) */
|
|
98
|
+
mfaTimeoutMs?: number;
|
|
99
|
+
/** Maximum MFA code submission attempts (default: 3) */
|
|
100
|
+
maxMfaAttempts?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// MFA Detection Patterns
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detection patterns for different MFA challenge types.
|
|
109
|
+
* Used by the Semantic Engine to identify MFA input fields.
|
|
110
|
+
*
|
|
111
|
+
* Requirements: 5.1, 5.3
|
|
112
|
+
*/
|
|
113
|
+
export const MFA_DETECTION_PATTERNS: MFADetectionPattern[] = [
|
|
114
|
+
{
|
|
115
|
+
type: 'sms',
|
|
116
|
+
keywords: [
|
|
117
|
+
'sms', 'text message', 'verification code', 'security code',
|
|
118
|
+
'phone code', 'mobile code', 'sent to your phone', 'sent a code',
|
|
119
|
+
'6-digit', '6 digit', 'enter code', 'enter the code',
|
|
120
|
+
],
|
|
121
|
+
inputPatterns: [
|
|
122
|
+
/^(sms|phone|mobile|verification|security|otp|code|token)[-_]?(code|input|field)?$/i,
|
|
123
|
+
/^(one[-_]?time[-_]?password|otp)$/i,
|
|
124
|
+
/^mfa[-_]?code$/i,
|
|
125
|
+
],
|
|
126
|
+
ariaRoles: ['textbox'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: 'authenticator',
|
|
130
|
+
keywords: [
|
|
131
|
+
'authenticator', 'google authenticator', 'microsoft authenticator',
|
|
132
|
+
'authy', 'totp', 'time-based', '2fa', 'two-factor',
|
|
133
|
+
'authentication app', 'authenticator app', '6-digit code',
|
|
134
|
+
],
|
|
135
|
+
inputPatterns: [
|
|
136
|
+
/^(totp|authenticator|2fa|two[-_]?factor)[-_]?(code|input|field)?$/i,
|
|
137
|
+
/^(auth[-_]?code|mfa[-_]?token)$/i,
|
|
138
|
+
],
|
|
139
|
+
ariaRoles: ['textbox'],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'push',
|
|
143
|
+
keywords: [
|
|
144
|
+
'push notification', 'approve', 'confirm on your device',
|
|
145
|
+
'check your phone', 'tap to approve', 'waiting for approval',
|
|
146
|
+
'sent a notification', 'open your app',
|
|
147
|
+
],
|
|
148
|
+
inputPatterns: [], // Push doesn't typically have input fields
|
|
149
|
+
ariaRoles: ['alert', 'status'],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: 'email',
|
|
153
|
+
keywords: [
|
|
154
|
+
'email code', 'sent to your email', 'check your email',
|
|
155
|
+
'email verification', 'verification email', 'magic link',
|
|
156
|
+
'email link', 'sent a link', 'emailed you',
|
|
157
|
+
],
|
|
158
|
+
inputPatterns: [
|
|
159
|
+
/^(email[-_]?code|email[-_]?verification|email[-_]?otp)$/i,
|
|
160
|
+
],
|
|
161
|
+
ariaRoles: ['textbox'],
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Auth Tunnel Implementation
|
|
167
|
+
// =============================================================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Auth Tunnel - MFA Relay
|
|
171
|
+
*
|
|
172
|
+
* Handles the human-in-the-loop authentication flow:
|
|
173
|
+
* 1. Detects MFA challenges in Shadow Sessions
|
|
174
|
+
* 2. Notifies users via dashboard/webhook
|
|
175
|
+
* 3. Injects MFA codes into sessions
|
|
176
|
+
* 4. Serializes successful auth state to vault
|
|
177
|
+
*
|
|
178
|
+
* Requirements: 5.1, 5.2, 5.3, 5.4, 5.6
|
|
179
|
+
*/
|
|
180
|
+
export class AuthTunnel {
|
|
181
|
+
private readonly vault?: SessionVault;
|
|
182
|
+
private readonly orchestrator?: ShadowSessionOrchestrator;
|
|
183
|
+
private readonly notificationConfig: NotificationConfig;
|
|
184
|
+
private readonly mfaTimeoutMs: number;
|
|
185
|
+
private readonly maxMfaAttempts: number;
|
|
186
|
+
|
|
187
|
+
/** Pending MFA challenges by ID */
|
|
188
|
+
private pendingChallenges: Map<string, PendingMFAChallenge> = new Map();
|
|
189
|
+
|
|
190
|
+
/** Timeout timers for challenges */
|
|
191
|
+
private timeoutTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
192
|
+
|
|
193
|
+
constructor(config: AuthTunnelConfig = {}) {
|
|
194
|
+
this.vault = config.vault;
|
|
195
|
+
this.orchestrator = config.orchestrator;
|
|
196
|
+
this.notificationConfig = config.notificationConfig ?? {};
|
|
197
|
+
this.mfaTimeoutMs = config.mfaTimeoutMs ?? MFA_TIMEOUT_MS;
|
|
198
|
+
this.maxMfaAttempts = config.maxMfaAttempts ?? 3;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get the orchestrator instance (for future browser automation).
|
|
203
|
+
*/
|
|
204
|
+
getOrchestrator(): ShadowSessionOrchestrator | undefined {
|
|
205
|
+
return this.orchestrator;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
// MFA Challenge Detection
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Generate a unique challenge ID.
|
|
214
|
+
*/
|
|
215
|
+
private generateChallengeId(): string {
|
|
216
|
+
return `mfa_${randomBytes(16).toString('hex')}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Detect MFA challenge type from page content.
|
|
221
|
+
* Uses keyword matching and input field analysis.
|
|
222
|
+
*
|
|
223
|
+
* Requirements: 5.1, 5.3
|
|
224
|
+
*/
|
|
225
|
+
detectMFAType(
|
|
226
|
+
pageContent: string,
|
|
227
|
+
intentDocument?: IntentDocument
|
|
228
|
+
): MFAChallenge['type'] | null {
|
|
229
|
+
const contentLower = pageContent.toLowerCase();
|
|
230
|
+
|
|
231
|
+
// Score each MFA type based on keyword matches
|
|
232
|
+
const scores: Record<MFAChallenge['type'], number> = {
|
|
233
|
+
sms: 0,
|
|
234
|
+
authenticator: 0,
|
|
235
|
+
push: 0,
|
|
236
|
+
email: 0,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const pattern of MFA_DETECTION_PATTERNS) {
|
|
240
|
+
// Check keywords
|
|
241
|
+
for (const keyword of pattern.keywords) {
|
|
242
|
+
if (contentLower.includes(keyword.toLowerCase())) {
|
|
243
|
+
scores[pattern.type] += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check input fields in intent document
|
|
248
|
+
if (intentDocument) {
|
|
249
|
+
for (const form of intentDocument.forms) {
|
|
250
|
+
for (const field of form.fields) {
|
|
251
|
+
for (const inputPattern of pattern.inputPatterns) {
|
|
252
|
+
if (
|
|
253
|
+
inputPattern.test(field.name) ||
|
|
254
|
+
inputPattern.test(field.intentId) ||
|
|
255
|
+
(field.placeholder && inputPattern.test(field.placeholder))
|
|
256
|
+
) {
|
|
257
|
+
scores[pattern.type] += 2; // Input field match is stronger signal
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check elements for ARIA roles
|
|
264
|
+
for (const element of intentDocument.elements) {
|
|
265
|
+
if (element.ariaRole && pattern.ariaRoles.includes(element.ariaRole)) {
|
|
266
|
+
// Check if element label contains MFA keywords
|
|
267
|
+
const labelLower = element.label.toLowerCase();
|
|
268
|
+
for (const keyword of pattern.keywords) {
|
|
269
|
+
if (labelLower.includes(keyword.toLowerCase())) {
|
|
270
|
+
scores[pattern.type] += 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Find the type with highest score
|
|
279
|
+
let maxScore = 0;
|
|
280
|
+
let detectedType: MFAChallenge['type'] | null = null;
|
|
281
|
+
|
|
282
|
+
for (const [type, score] of Object.entries(scores)) {
|
|
283
|
+
if (score > maxScore) {
|
|
284
|
+
maxScore = score;
|
|
285
|
+
detectedType = type as MFAChallenge['type'];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Require minimum score to confirm detection
|
|
290
|
+
return maxScore >= 2 ? detectedType : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Find MFA input field in the intent document.
|
|
295
|
+
* Uses Semantic Engine to identify the correct input field.
|
|
296
|
+
*
|
|
297
|
+
* Requirements: 5.1, 5.3
|
|
298
|
+
*/
|
|
299
|
+
findMFAInputField(
|
|
300
|
+
intentDocument: IntentDocument,
|
|
301
|
+
mfaType: MFAChallenge['type']
|
|
302
|
+
): IntentElement | null {
|
|
303
|
+
const pattern = MFA_DETECTION_PATTERNS.find((p) => p.type === mfaType);
|
|
304
|
+
if (!pattern) return null;
|
|
305
|
+
|
|
306
|
+
// First, check form fields
|
|
307
|
+
for (const form of intentDocument.forms) {
|
|
308
|
+
for (const field of form.fields) {
|
|
309
|
+
// Check input patterns
|
|
310
|
+
for (const inputPattern of pattern.inputPatterns) {
|
|
311
|
+
if (
|
|
312
|
+
inputPattern.test(field.name) ||
|
|
313
|
+
inputPattern.test(field.intentId) ||
|
|
314
|
+
(field.placeholder && inputPattern.test(field.placeholder))
|
|
315
|
+
) {
|
|
316
|
+
// Find corresponding element
|
|
317
|
+
const element = intentDocument.elements.find(
|
|
318
|
+
(el) => el.intentId === field.intentId
|
|
319
|
+
);
|
|
320
|
+
if (element) return element;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Check for generic code input fields
|
|
325
|
+
if (
|
|
326
|
+
field.type === 'text' ||
|
|
327
|
+
field.type === 'number' ||
|
|
328
|
+
field.type === 'tel'
|
|
329
|
+
) {
|
|
330
|
+
const fieldLower = (field.label + field.name + (field.placeholder || '')).toLowerCase();
|
|
331
|
+
for (const keyword of pattern.keywords) {
|
|
332
|
+
if (fieldLower.includes(keyword.toLowerCase())) {
|
|
333
|
+
const element = intentDocument.elements.find(
|
|
334
|
+
(el) => el.intentId === field.intentId
|
|
335
|
+
);
|
|
336
|
+
if (element) return element;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fallback: check elements directly
|
|
344
|
+
for (const element of intentDocument.elements) {
|
|
345
|
+
if (element.role === 'input') {
|
|
346
|
+
const labelLower = element.label.toLowerCase();
|
|
347
|
+
for (const keyword of pattern.keywords) {
|
|
348
|
+
if (labelLower.includes(keyword.toLowerCase())) {
|
|
349
|
+
return element;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Generate user-friendly prompt for MFA challenge.
|
|
360
|
+
*/
|
|
361
|
+
private generatePrompt(mfaType: MFAChallenge['type']): string {
|
|
362
|
+
switch (mfaType) {
|
|
363
|
+
case 'sms':
|
|
364
|
+
return 'Enter the verification code sent to your phone via SMS';
|
|
365
|
+
case 'authenticator':
|
|
366
|
+
return 'Enter the 6-digit code from your authenticator app';
|
|
367
|
+
case 'push':
|
|
368
|
+
return 'Approve the login request on your mobile device';
|
|
369
|
+
case 'email':
|
|
370
|
+
return 'Enter the verification code sent to your email';
|
|
371
|
+
default:
|
|
372
|
+
return 'Complete the verification to continue';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Detect MFA challenge in a Shadow Session.
|
|
378
|
+
* Returns the challenge if detected, null otherwise.
|
|
379
|
+
*
|
|
380
|
+
* Requirements: 5.1, 5.3
|
|
381
|
+
*/
|
|
382
|
+
async detectMFA(
|
|
383
|
+
session: ShadowSession,
|
|
384
|
+
pageContent: string,
|
|
385
|
+
intentDocument?: IntentDocument
|
|
386
|
+
): Promise<MFAChallenge | null> {
|
|
387
|
+
// Detect MFA type
|
|
388
|
+
const mfaType = this.detectMFAType(pageContent, intentDocument);
|
|
389
|
+
if (!mfaType) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Create MFA challenge
|
|
394
|
+
const challenge: MFAChallenge = {
|
|
395
|
+
id: this.generateChallengeId(),
|
|
396
|
+
sessionId: session.id,
|
|
397
|
+
type: mfaType,
|
|
398
|
+
prompt: this.generatePrompt(mfaType),
|
|
399
|
+
expiresAt: Date.now() + this.mfaTimeoutMs,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return challenge;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ===========================================================================
|
|
406
|
+
// User Notification
|
|
407
|
+
// ===========================================================================
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Send notification to user about MFA challenge.
|
|
411
|
+
* Supports dashboard callback and webhook notifications.
|
|
412
|
+
*
|
|
413
|
+
* Requirements: 5.1
|
|
414
|
+
*/
|
|
415
|
+
async requestCode(challenge: MFAChallenge): Promise<void> {
|
|
416
|
+
// Store as pending challenge
|
|
417
|
+
const pendingChallenge: PendingMFAChallenge = {
|
|
418
|
+
...challenge,
|
|
419
|
+
notified: false,
|
|
420
|
+
attempts: 0,
|
|
421
|
+
maxAttempts: this.maxMfaAttempts,
|
|
422
|
+
};
|
|
423
|
+
this.pendingChallenges.set(challenge.id, pendingChallenge);
|
|
424
|
+
|
|
425
|
+
// Set up timeout timer
|
|
426
|
+
this.setupTimeoutTimer(challenge.id);
|
|
427
|
+
|
|
428
|
+
// Send dashboard notification
|
|
429
|
+
if (this.notificationConfig.dashboardCallback) {
|
|
430
|
+
try {
|
|
431
|
+
await this.notificationConfig.dashboardCallback(challenge);
|
|
432
|
+
pendingChallenge.notified = true;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
console.error('Failed to send dashboard notification:', error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Send webhook notification
|
|
439
|
+
if (this.notificationConfig.webhookUrl) {
|
|
440
|
+
try {
|
|
441
|
+
await this.sendWebhookNotification(challenge);
|
|
442
|
+
pendingChallenge.notified = true;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error('Failed to send webhook notification:', error);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// If no notification method configured, mark as notified anyway
|
|
449
|
+
// (for testing or manual code entry scenarios)
|
|
450
|
+
if (!this.notificationConfig.dashboardCallback && !this.notificationConfig.webhookUrl) {
|
|
451
|
+
pendingChallenge.notified = true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Send webhook notification for MFA challenge.
|
|
457
|
+
*/
|
|
458
|
+
private async sendWebhookNotification(challenge: MFAChallenge): Promise<void> {
|
|
459
|
+
if (!this.notificationConfig.webhookUrl) return;
|
|
460
|
+
|
|
461
|
+
const payload = {
|
|
462
|
+
type: 'mfa_challenge',
|
|
463
|
+
challenge: {
|
|
464
|
+
id: challenge.id,
|
|
465
|
+
sessionId: challenge.sessionId,
|
|
466
|
+
type: challenge.type,
|
|
467
|
+
prompt: challenge.prompt,
|
|
468
|
+
expiresAt: challenge.expiresAt,
|
|
469
|
+
},
|
|
470
|
+
timestamp: Date.now(),
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// In a real implementation, this would use fetch
|
|
474
|
+
// For now, we'll simulate the webhook call
|
|
475
|
+
console.log('Webhook notification:', this.notificationConfig.webhookUrl, payload);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Set up timeout timer for MFA challenge.
|
|
480
|
+
*
|
|
481
|
+
* Requirements: 5.6
|
|
482
|
+
*/
|
|
483
|
+
private setupTimeoutTimer(challengeId: string): void {
|
|
484
|
+
// Clear existing timer if any
|
|
485
|
+
this.clearTimeoutTimer(challengeId);
|
|
486
|
+
|
|
487
|
+
const challenge = this.pendingChallenges.get(challengeId);
|
|
488
|
+
if (!challenge) return;
|
|
489
|
+
|
|
490
|
+
const timeoutMs = challenge.expiresAt - Date.now();
|
|
491
|
+
if (timeoutMs <= 0) {
|
|
492
|
+
this.handleTimeout(challengeId);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const timer = setTimeout(() => {
|
|
497
|
+
this.handleTimeout(challengeId);
|
|
498
|
+
}, timeoutMs);
|
|
499
|
+
|
|
500
|
+
this.timeoutTimers.set(challengeId, timer);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Clear timeout timer for a challenge.
|
|
505
|
+
*/
|
|
506
|
+
private clearTimeoutTimer(challengeId: string): void {
|
|
507
|
+
const timer = this.timeoutTimers.get(challengeId);
|
|
508
|
+
if (timer) {
|
|
509
|
+
clearTimeout(timer);
|
|
510
|
+
this.timeoutTimers.delete(challengeId);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Handle MFA timeout.
|
|
516
|
+
*
|
|
517
|
+
* Requirements: 5.6
|
|
518
|
+
*/
|
|
519
|
+
private handleTimeout(challengeId: string): void {
|
|
520
|
+
const challenge = this.pendingChallenges.get(challengeId);
|
|
521
|
+
if (!challenge) return;
|
|
522
|
+
|
|
523
|
+
// Reject the challenge with timeout error
|
|
524
|
+
if (challenge.reject) {
|
|
525
|
+
challenge.reject({
|
|
526
|
+
code: 'timeout',
|
|
527
|
+
message: 'MFA challenge timed out after 5 minutes',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Clean up
|
|
532
|
+
this.pendingChallenges.delete(challengeId);
|
|
533
|
+
this.clearTimeoutTimer(challengeId);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ===========================================================================
|
|
537
|
+
// Code Injection
|
|
538
|
+
// ===========================================================================
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Inject MFA code into Shadow Session.
|
|
542
|
+
* Serializes successful auth state to vault.
|
|
543
|
+
*
|
|
544
|
+
* Requirements: 5.2, 5.4
|
|
545
|
+
*/
|
|
546
|
+
async injectCode(
|
|
547
|
+
challenge: MFAChallenge,
|
|
548
|
+
code: string
|
|
549
|
+
): Promise<AuthResult> {
|
|
550
|
+
const pendingChallenge = this.pendingChallenges.get(challenge.id);
|
|
551
|
+
|
|
552
|
+
// Check if challenge exists - if not, check if it was timed out
|
|
553
|
+
if (!pendingChallenge) {
|
|
554
|
+
// Check if the original challenge has expired
|
|
555
|
+
if (Date.now() > challenge.expiresAt) {
|
|
556
|
+
return {
|
|
557
|
+
success: false,
|
|
558
|
+
error: {
|
|
559
|
+
code: 'timeout',
|
|
560
|
+
message: 'MFA challenge has expired',
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
success: false,
|
|
566
|
+
error: {
|
|
567
|
+
code: 'mfa_failed',
|
|
568
|
+
message: 'MFA challenge not found or already completed',
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Check if challenge has expired
|
|
574
|
+
if (Date.now() > pendingChallenge.expiresAt) {
|
|
575
|
+
this.pendingChallenges.delete(challenge.id);
|
|
576
|
+
this.clearTimeoutTimer(challenge.id);
|
|
577
|
+
return {
|
|
578
|
+
success: false,
|
|
579
|
+
error: {
|
|
580
|
+
code: 'timeout',
|
|
581
|
+
message: 'MFA challenge has expired',
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Check attempt count
|
|
587
|
+
pendingChallenge.attempts++;
|
|
588
|
+
if (pendingChallenge.attempts > pendingChallenge.maxAttempts) {
|
|
589
|
+
this.pendingChallenges.delete(challenge.id);
|
|
590
|
+
this.clearTimeoutTimer(challenge.id);
|
|
591
|
+
return {
|
|
592
|
+
success: false,
|
|
593
|
+
error: {
|
|
594
|
+
code: 'mfa_failed',
|
|
595
|
+
message: 'Maximum MFA attempts exceeded',
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Validate code format
|
|
601
|
+
if (!this.validateCodeFormat(code, challenge.type)) {
|
|
602
|
+
return {
|
|
603
|
+
success: false,
|
|
604
|
+
error: {
|
|
605
|
+
code: 'mfa_failed',
|
|
606
|
+
message: 'Invalid code format',
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// In a real implementation, this would:
|
|
612
|
+
// 1. Get the Shadow Session from orchestrator
|
|
613
|
+
// 2. Find the MFA input field using Semantic Engine
|
|
614
|
+
// 3. Type the code into the field
|
|
615
|
+
// 4. Submit the form
|
|
616
|
+
// 5. Check if authentication succeeded
|
|
617
|
+
// 6. Serialize session state to vault
|
|
618
|
+
|
|
619
|
+
// For now, we simulate successful code injection
|
|
620
|
+
const sessionState: RawSessionData = {
|
|
621
|
+
cookies: JSON.stringify([
|
|
622
|
+
{ name: 'auth_token', value: `authenticated_${challenge.sessionId}`, domain: 'example.com' },
|
|
623
|
+
]),
|
|
624
|
+
localStorage: JSON.stringify({ authenticated: true }),
|
|
625
|
+
sessionStorage: JSON.stringify({ mfa_completed: true }),
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Store in vault if available
|
|
629
|
+
if (this.vault) {
|
|
630
|
+
// Extract domain from session (would come from orchestrator in real impl)
|
|
631
|
+
const domain = 'example.com'; // Placeholder
|
|
632
|
+
await this.vault.store(domain, sessionState);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Clean up challenge
|
|
636
|
+
this.pendingChallenges.delete(challenge.id);
|
|
637
|
+
this.clearTimeoutTimer(challenge.id);
|
|
638
|
+
|
|
639
|
+
// Resolve the challenge if callback exists
|
|
640
|
+
if (pendingChallenge.resolve) {
|
|
641
|
+
pendingChallenge.resolve(code);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
success: true,
|
|
646
|
+
sessionState: {
|
|
647
|
+
cookies: this.vault?.encrypt(sessionState.cookies) ?? { data: '', iv: '', algorithm: 'AES-256-GCM' },
|
|
648
|
+
localStorage: this.vault?.encrypt(sessionState.localStorage) ?? { data: '', iv: '', algorithm: 'AES-256-GCM' },
|
|
649
|
+
sessionStorage: this.vault?.encrypt(sessionState.sessionStorage) ?? { data: '', iv: '', algorithm: 'AES-256-GCM' },
|
|
650
|
+
expiresAt: Date.now() + 86400000, // 24 hours
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Validate MFA code format based on challenge type.
|
|
657
|
+
*/
|
|
658
|
+
private validateCodeFormat(code: string, type: MFAChallenge['type']): boolean {
|
|
659
|
+
if (!code || code.trim().length === 0) {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
switch (type) {
|
|
664
|
+
case 'sms':
|
|
665
|
+
case 'authenticator':
|
|
666
|
+
// Typically 6-digit codes
|
|
667
|
+
return /^\d{4,8}$/.test(code.trim());
|
|
668
|
+
case 'email':
|
|
669
|
+
// Could be numeric code or alphanumeric
|
|
670
|
+
return /^[A-Za-z0-9]{4,10}$/.test(code.trim());
|
|
671
|
+
case 'push':
|
|
672
|
+
// Push doesn't require code input
|
|
673
|
+
return true;
|
|
674
|
+
default:
|
|
675
|
+
return code.trim().length > 0;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ===========================================================================
|
|
680
|
+
// Timeout Handling
|
|
681
|
+
// ===========================================================================
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Wait for MFA code with timeout.
|
|
685
|
+
* Returns the auth result or timeout error.
|
|
686
|
+
*
|
|
687
|
+
* Requirements: 5.6
|
|
688
|
+
*/
|
|
689
|
+
async waitForCode(challenge: MFAChallenge): Promise<AuthResult> {
|
|
690
|
+
return new Promise((resolve) => {
|
|
691
|
+
const pendingChallenge = this.pendingChallenges.get(challenge.id);
|
|
692
|
+
if (!pendingChallenge) {
|
|
693
|
+
resolve({
|
|
694
|
+
success: false,
|
|
695
|
+
error: {
|
|
696
|
+
code: 'mfa_failed',
|
|
697
|
+
message: 'MFA challenge not found',
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Set up resolve/reject callbacks
|
|
704
|
+
pendingChallenge.resolve = () => {
|
|
705
|
+
resolve({
|
|
706
|
+
success: true,
|
|
707
|
+
});
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
pendingChallenge.reject = (error: AuthError) => {
|
|
711
|
+
resolve({
|
|
712
|
+
success: false,
|
|
713
|
+
error,
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// Check if already expired
|
|
718
|
+
if (Date.now() > pendingChallenge.expiresAt) {
|
|
719
|
+
this.handleTimeout(challenge.id);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Check if a challenge has timed out.
|
|
726
|
+
*
|
|
727
|
+
* Requirements: 5.6
|
|
728
|
+
*/
|
|
729
|
+
isTimedOut(challengeId: string): boolean {
|
|
730
|
+
const challenge = this.pendingChallenges.get(challengeId);
|
|
731
|
+
if (!challenge) return true;
|
|
732
|
+
return Date.now() > challenge.expiresAt;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Get remaining time for a challenge in milliseconds.
|
|
737
|
+
*/
|
|
738
|
+
getRemainingTime(challengeId: string): number {
|
|
739
|
+
const challenge = this.pendingChallenges.get(challengeId);
|
|
740
|
+
if (!challenge) return 0;
|
|
741
|
+
return Math.max(0, challenge.expiresAt - Date.now());
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ===========================================================================
|
|
745
|
+
// Challenge Management
|
|
746
|
+
// ===========================================================================
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Get a pending challenge by ID.
|
|
750
|
+
*/
|
|
751
|
+
getChallenge(challengeId: string): MFAChallenge | null {
|
|
752
|
+
const pending = this.pendingChallenges.get(challengeId);
|
|
753
|
+
if (!pending) return null;
|
|
754
|
+
|
|
755
|
+
// Return public challenge (without internal callbacks)
|
|
756
|
+
return {
|
|
757
|
+
id: pending.id,
|
|
758
|
+
sessionId: pending.sessionId,
|
|
759
|
+
type: pending.type,
|
|
760
|
+
prompt: pending.prompt,
|
|
761
|
+
expiresAt: pending.expiresAt,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Get all pending challenges for a session.
|
|
767
|
+
*/
|
|
768
|
+
getChallengesForSession(sessionId: string): MFAChallenge[] {
|
|
769
|
+
const challenges: MFAChallenge[] = [];
|
|
770
|
+
for (const pending of this.pendingChallenges.values()) {
|
|
771
|
+
if (pending.sessionId === sessionId) {
|
|
772
|
+
challenges.push({
|
|
773
|
+
id: pending.id,
|
|
774
|
+
sessionId: pending.sessionId,
|
|
775
|
+
type: pending.type,
|
|
776
|
+
prompt: pending.prompt,
|
|
777
|
+
expiresAt: pending.expiresAt,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return challenges;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Cancel a pending challenge.
|
|
786
|
+
*/
|
|
787
|
+
cancelChallenge(challengeId: string): boolean {
|
|
788
|
+
const challenge = this.pendingChallenges.get(challengeId);
|
|
789
|
+
if (!challenge) return false;
|
|
790
|
+
|
|
791
|
+
if (challenge.reject) {
|
|
792
|
+
challenge.reject({
|
|
793
|
+
code: 'mfa_failed',
|
|
794
|
+
message: 'MFA challenge was cancelled',
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
this.pendingChallenges.delete(challengeId);
|
|
799
|
+
this.clearTimeoutTimer(challengeId);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Get count of pending challenges.
|
|
805
|
+
*/
|
|
806
|
+
getPendingChallengeCount(): number {
|
|
807
|
+
return this.pendingChallenges.size;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Clear all pending challenges (for testing).
|
|
812
|
+
*/
|
|
813
|
+
clear(): void {
|
|
814
|
+
// Clear all timeout timers
|
|
815
|
+
for (const challengeId of this.timeoutTimers.keys()) {
|
|
816
|
+
this.clearTimeoutTimer(challengeId);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Reject all pending challenges
|
|
820
|
+
for (const challenge of this.pendingChallenges.values()) {
|
|
821
|
+
if (challenge.reject) {
|
|
822
|
+
challenge.reject({
|
|
823
|
+
code: 'mfa_failed',
|
|
824
|
+
message: 'Auth tunnel was cleared',
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
this.pendingChallenges.clear();
|
|
830
|
+
this.timeoutTimers.clear();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// =============================================================================
|
|
835
|
+
// Factory Function
|
|
836
|
+
// =============================================================================
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Create a new Auth Tunnel instance.
|
|
840
|
+
*/
|
|
841
|
+
export function createAuthTunnel(config?: AuthTunnelConfig): AuthTunnel {
|
|
842
|
+
return new AuthTunnel(config);
|
|
843
|
+
}
|