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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score Agent - Agent scoring and winner determination
|
|
3
|
+
*
|
|
4
|
+
* Determines the winner of a league based on evaluation results and metrics.
|
|
5
|
+
*
|
|
6
|
+
* @module league/scoreAgent
|
|
7
|
+
* @see Requirements 4.5
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { EvaluationResult } from '../eval/evaluateOutcome.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Metrics collected during an agent's run.
|
|
14
|
+
*/
|
|
15
|
+
export interface AgentMetrics {
|
|
16
|
+
/** Agent ID */
|
|
17
|
+
agentId: string;
|
|
18
|
+
/** Tokens spent */
|
|
19
|
+
tokensSpent: number;
|
|
20
|
+
/** Number of attempts made */
|
|
21
|
+
attempts: number;
|
|
22
|
+
/** Duration in milliseconds */
|
|
23
|
+
durationMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Score for an agent's performance.
|
|
28
|
+
*/
|
|
29
|
+
export interface AgentScore {
|
|
30
|
+
/** Agent ID */
|
|
31
|
+
agentId: string;
|
|
32
|
+
/** Whether the agent achieved success */
|
|
33
|
+
success: boolean;
|
|
34
|
+
/** Tokens spent */
|
|
35
|
+
cost: number;
|
|
36
|
+
/** Number of attempts made */
|
|
37
|
+
attempts: number;
|
|
38
|
+
/** Duration in milliseconds */
|
|
39
|
+
duration: number;
|
|
40
|
+
/** Timestamp when success was achieved (if applicable) */
|
|
41
|
+
successTimestamp?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scores an agent based on evaluation result and metrics.
|
|
46
|
+
*
|
|
47
|
+
* @param result - Evaluation result from the agent's artifact
|
|
48
|
+
* @param metrics - Metrics collected during the run
|
|
49
|
+
* @returns AgentScore for ranking
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const score = scoreAgent(evalResult, {
|
|
53
|
+
* agentId: 'agent-1',
|
|
54
|
+
* tokensSpent: 500,
|
|
55
|
+
* attempts: 2,
|
|
56
|
+
* durationMs: 5000
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* @see Requirements 4.5
|
|
60
|
+
*/
|
|
61
|
+
export function scoreAgent(
|
|
62
|
+
result: EvaluationResult,
|
|
63
|
+
metrics: AgentMetrics
|
|
64
|
+
): AgentScore {
|
|
65
|
+
return {
|
|
66
|
+
agentId: metrics.agentId,
|
|
67
|
+
success: result.status === 'SUCCESS',
|
|
68
|
+
cost: metrics.tokensSpent,
|
|
69
|
+
attempts: metrics.attempts,
|
|
70
|
+
duration: metrics.durationMs,
|
|
71
|
+
successTimestamp: result.status === 'SUCCESS' ? Date.now() : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Determines the winner from a list of agent scores.
|
|
77
|
+
*
|
|
78
|
+
* Winner criteria (in order):
|
|
79
|
+
* 1. Must have success === true
|
|
80
|
+
* 2. Earliest successTimestamp wins (first to succeed)
|
|
81
|
+
*
|
|
82
|
+
* @param scores - Array of agent scores
|
|
83
|
+
* @returns Winning AgentScore or null if no winner
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const winner = determineWinner(scores);
|
|
87
|
+
* if (winner) {
|
|
88
|
+
* console.log(`Winner: ${winner.agentId}`);
|
|
89
|
+
* }
|
|
90
|
+
*
|
|
91
|
+
* @see Requirements 4.5
|
|
92
|
+
*/
|
|
93
|
+
export function determineWinner(scores: AgentScore[]): AgentScore | null {
|
|
94
|
+
// Filter to only successful agents
|
|
95
|
+
const successfulAgents = scores.filter((s) => s.success);
|
|
96
|
+
|
|
97
|
+
if (successfulAgents.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Sort by success timestamp (earliest first)
|
|
102
|
+
successfulAgents.sort((a, b) => {
|
|
103
|
+
const timeA = a.successTimestamp ?? Infinity;
|
|
104
|
+
const timeB = b.successTimestamp ?? Infinity;
|
|
105
|
+
return timeA - timeB;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return successfulAgents[0];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ranks all agents by performance.
|
|
113
|
+
*
|
|
114
|
+
* Ranking criteria:
|
|
115
|
+
* 1. Success status (successful agents rank higher)
|
|
116
|
+
* 2. For successful: earlier success timestamp
|
|
117
|
+
* 3. For failed: fewer attempts, lower cost
|
|
118
|
+
*
|
|
119
|
+
* @param scores - Array of agent scores
|
|
120
|
+
* @returns Sorted array with best performer first
|
|
121
|
+
*/
|
|
122
|
+
export function rankAgents(scores: AgentScore[]): AgentScore[] {
|
|
123
|
+
return [...scores].sort((a, b) => {
|
|
124
|
+
// Successful agents rank higher
|
|
125
|
+
if (a.success && !b.success) return -1;
|
|
126
|
+
if (!a.success && b.success) return 1;
|
|
127
|
+
|
|
128
|
+
if (a.success && b.success) {
|
|
129
|
+
// Both successful: earlier timestamp wins
|
|
130
|
+
const timeA = a.successTimestamp ?? Infinity;
|
|
131
|
+
const timeB = b.successTimestamp ?? Infinity;
|
|
132
|
+
return timeA - timeB;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Both failed: fewer attempts is better
|
|
136
|
+
if (a.attempts !== b.attempts) {
|
|
137
|
+
return a.attempts - b.attempts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Same attempts: lower cost is better
|
|
141
|
+
return a.cost - b.cost;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Calculates aggregate statistics for a league run.
|
|
147
|
+
*
|
|
148
|
+
* @param scores - Array of agent scores
|
|
149
|
+
* @returns Aggregate statistics
|
|
150
|
+
*/
|
|
151
|
+
export function calculateLeagueStats(scores: AgentScore[]): {
|
|
152
|
+
totalAgents: number;
|
|
153
|
+
successfulAgents: number;
|
|
154
|
+
failedAgents: number;
|
|
155
|
+
totalCost: number;
|
|
156
|
+
averageCost: number;
|
|
157
|
+
totalAttempts: number;
|
|
158
|
+
averageAttempts: number;
|
|
159
|
+
} {
|
|
160
|
+
const totalAgents = scores.length;
|
|
161
|
+
const successfulAgents = scores.filter((s) => s.success).length;
|
|
162
|
+
const failedAgents = totalAgents - successfulAgents;
|
|
163
|
+
const totalCost = scores.reduce((sum, s) => sum + s.cost, 0);
|
|
164
|
+
const totalAttempts = scores.reduce((sum, s) => sum + s.attempts, 0);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
totalAgents,
|
|
168
|
+
successfulAgents,
|
|
169
|
+
failedAgents,
|
|
170
|
+
totalCost,
|
|
171
|
+
averageCost: totalAgents > 0 ? totalCost / totalAgents : 0,
|
|
172
|
+
totalAttempts,
|
|
173
|
+
averageAttempts: totalAgents > 0 ? totalAttempts / totalAgents : 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests - Unit tests and Property-based tests
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-Based Tests for Auth Tunnel (MFA Relay)
|
|
3
|
+
*
|
|
4
|
+
* These tests validate the correctness properties defined in the design document.
|
|
5
|
+
* Each property test runs minimum 100 iterations with randomly generated inputs.
|
|
6
|
+
*
|
|
7
|
+
* **Property 11: MFA Timeout**
|
|
8
|
+
* **Validates: Requirements 5.6**
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
12
|
+
import * as fc from 'fast-check';
|
|
13
|
+
import {
|
|
14
|
+
AuthTunnel,
|
|
15
|
+
createAuthTunnel,
|
|
16
|
+
MFA_TIMEOUT_MS,
|
|
17
|
+
} from '../auth/auth-tunnel.js';
|
|
18
|
+
import type { MFAChallenge, ShadowSession } from '../core/types.js';
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Test Setup
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
let authTunnel: AuthTunnel;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
authTunnel = createAuthTunnel({
|
|
29
|
+
mfaTimeoutMs: MFA_TIMEOUT_MS, // 5 minutes = 300000ms
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
authTunnel.clear();
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Arbitraries (Test Data Generators)
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generate a valid session ID.
|
|
44
|
+
*/
|
|
45
|
+
const sessionIdArb = fc.stringMatching(/^shadow_[a-f0-9]{32}$/);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate MFA challenge types.
|
|
49
|
+
*/
|
|
50
|
+
const mfaTypeArb: fc.Arbitrary<MFAChallenge['type']> = fc.constantFrom(
|
|
51
|
+
'sms',
|
|
52
|
+
'authenticator',
|
|
53
|
+
'push',
|
|
54
|
+
'email'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate a mock Shadow Session.
|
|
59
|
+
* Uses fixed timestamps relative to a base time to ensure deterministic test behavior.
|
|
60
|
+
*/
|
|
61
|
+
const BASE_TIME = 1700000000000; // Fixed base timestamp for deterministic tests
|
|
62
|
+
|
|
63
|
+
const shadowSessionArb: fc.Arbitrary<ShadowSession> = fc.record({
|
|
64
|
+
id: sessionIdArb,
|
|
65
|
+
domain: fc.stringMatching(/^[a-z][a-z0-9-]{2,20}\.(com|org|net|io)$/),
|
|
66
|
+
status: fc.constant('active' as const),
|
|
67
|
+
createdAt: fc.integer({ min: BASE_TIME - 3600000, max: BASE_TIME }),
|
|
68
|
+
lastHeartbeat: fc.integer({ min: BASE_TIME - 60000, max: BASE_TIME }),
|
|
69
|
+
fingerprint: fc.record({
|
|
70
|
+
resolution: fc.record({
|
|
71
|
+
width: fc.integer({ min: 800, max: 3840 }),
|
|
72
|
+
height: fc.integer({ min: 600, max: 2160 }),
|
|
73
|
+
}),
|
|
74
|
+
userAgent: fc.constant('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
|
|
75
|
+
fonts: fc.array(fc.string(), { minLength: 1, maxLength: 5 }),
|
|
76
|
+
gpuSignature: fc.string(),
|
|
77
|
+
timezone: fc.constant('America/New_York'),
|
|
78
|
+
language: fc.constant('en-US'),
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate page content with MFA keywords.
|
|
84
|
+
*/
|
|
85
|
+
const mfaPageContentArb = (type: MFAChallenge['type']): fc.Arbitrary<string> => {
|
|
86
|
+
const keywordsByType: Record<MFAChallenge['type'], string[]> = {
|
|
87
|
+
sms: ['Enter the verification code sent to your phone', 'SMS code', '6-digit code'],
|
|
88
|
+
authenticator: ['Enter the code from your authenticator app', 'Google Authenticator', 'TOTP'],
|
|
89
|
+
push: ['Approve the login request on your device', 'Push notification sent'],
|
|
90
|
+
email: ['Enter the code sent to your email', 'Email verification'],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return fc.constantFrom(...keywordsByType[type]).map((keyword) => {
|
|
94
|
+
return `<html><body><h1>Verification Required</h1><p>${keyword}</p><input type="text" name="code" /></body></html>`;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate valid MFA codes.
|
|
100
|
+
*/
|
|
101
|
+
const validMfaCodeArb = fc.stringMatching(/^\d{6}$/);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate time delays in milliseconds.
|
|
105
|
+
*/
|
|
106
|
+
const timeDelayArb = fc.integer({ min: 0, max: MFA_TIMEOUT_MS + 60000 });
|
|
107
|
+
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// Property Tests
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
describe('Auth Tunnel Property Tests', () => {
|
|
113
|
+
/**
|
|
114
|
+
* **Feature: omnibridge, Property 11: MFA Timeout**
|
|
115
|
+
*
|
|
116
|
+
* *For any* MFA challenge, if no response is received within 5 minutes (300 seconds),
|
|
117
|
+
* the Auth_Tunnel SHALL return a timeout error.
|
|
118
|
+
*
|
|
119
|
+
* **Validates: Requirements 5.6**
|
|
120
|
+
*/
|
|
121
|
+
test('Property 11: MFA Timeout - challenges timeout after 5 minutes', async () => {
|
|
122
|
+
await fc.assert(
|
|
123
|
+
fc.asyncProperty(
|
|
124
|
+
shadowSessionArb,
|
|
125
|
+
mfaTypeArb,
|
|
126
|
+
async (session, mfaType) => {
|
|
127
|
+
// Generate page content for the MFA type
|
|
128
|
+
const pageContent = await fc.sample(mfaPageContentArb(mfaType), 1)[0];
|
|
129
|
+
|
|
130
|
+
// Detect MFA challenge
|
|
131
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
132
|
+
|
|
133
|
+
// If no challenge detected, skip this iteration
|
|
134
|
+
if (!challenge) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Request code (starts the timeout timer)
|
|
139
|
+
await authTunnel.requestCode(challenge);
|
|
140
|
+
|
|
141
|
+
// Verify challenge is pending
|
|
142
|
+
expect(authTunnel.getChallenge(challenge.id)).not.toBeNull();
|
|
143
|
+
expect(authTunnel.isTimedOut(challenge.id)).toBe(false);
|
|
144
|
+
|
|
145
|
+
// PROPERTY: Before timeout, challenge should be valid
|
|
146
|
+
const remainingBefore = authTunnel.getRemainingTime(challenge.id);
|
|
147
|
+
expect(remainingBefore).toBeGreaterThan(0);
|
|
148
|
+
expect(remainingBefore).toBeLessThanOrEqual(MFA_TIMEOUT_MS);
|
|
149
|
+
|
|
150
|
+
// Advance time past the timeout (5 minutes + 1 second)
|
|
151
|
+
vi.advanceTimersByTime(MFA_TIMEOUT_MS + 1000);
|
|
152
|
+
|
|
153
|
+
// PROPERTY: After 5 minutes, challenge should be timed out
|
|
154
|
+
expect(authTunnel.isTimedOut(challenge.id)).toBe(true);
|
|
155
|
+
expect(authTunnel.getRemainingTime(challenge.id)).toBe(0);
|
|
156
|
+
|
|
157
|
+
// PROPERTY: Attempting to inject code after timeout should fail
|
|
158
|
+
const result = await authTunnel.injectCode(challenge, '123456');
|
|
159
|
+
expect(result.success).toBe(false);
|
|
160
|
+
expect(result.error).toBeDefined();
|
|
161
|
+
expect(result.error?.code).toBe('timeout');
|
|
162
|
+
|
|
163
|
+
// Clean up for next iteration
|
|
164
|
+
authTunnel.clear();
|
|
165
|
+
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
),
|
|
169
|
+
{ numRuns: 100 }
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Additional property: Challenges within timeout window should be valid.
|
|
175
|
+
*/
|
|
176
|
+
test('Property 11a: Challenges within timeout window remain valid', async () => {
|
|
177
|
+
await fc.assert(
|
|
178
|
+
fc.asyncProperty(
|
|
179
|
+
shadowSessionArb,
|
|
180
|
+
mfaTypeArb,
|
|
181
|
+
fc.integer({ min: 1000, max: MFA_TIMEOUT_MS - 1000 }), // Time within window
|
|
182
|
+
async (session, mfaType, timeWithinWindow) => {
|
|
183
|
+
const pageContent = await fc.sample(mfaPageContentArb(mfaType), 1)[0];
|
|
184
|
+
|
|
185
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
186
|
+
if (!challenge) return true;
|
|
187
|
+
|
|
188
|
+
await authTunnel.requestCode(challenge);
|
|
189
|
+
|
|
190
|
+
// Advance time but stay within timeout window
|
|
191
|
+
vi.advanceTimersByTime(timeWithinWindow);
|
|
192
|
+
|
|
193
|
+
// PROPERTY: Challenge should still be valid
|
|
194
|
+
expect(authTunnel.isTimedOut(challenge.id)).toBe(false);
|
|
195
|
+
expect(authTunnel.getRemainingTime(challenge.id)).toBeGreaterThan(0);
|
|
196
|
+
|
|
197
|
+
// Challenge should still be retrievable
|
|
198
|
+
const retrievedChallenge = authTunnel.getChallenge(challenge.id);
|
|
199
|
+
expect(retrievedChallenge).not.toBeNull();
|
|
200
|
+
expect(retrievedChallenge?.id).toBe(challenge.id);
|
|
201
|
+
|
|
202
|
+
authTunnel.clear();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
),
|
|
206
|
+
{ numRuns: 100 }
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Additional property: Successful code injection before timeout should work.
|
|
212
|
+
*/
|
|
213
|
+
test('Property 11b: Code injection before timeout succeeds', async () => {
|
|
214
|
+
await fc.assert(
|
|
215
|
+
fc.asyncProperty(
|
|
216
|
+
shadowSessionArb,
|
|
217
|
+
mfaTypeArb,
|
|
218
|
+
validMfaCodeArb,
|
|
219
|
+
fc.integer({ min: 0, max: MFA_TIMEOUT_MS - 10000 }), // Time before timeout
|
|
220
|
+
async (session, mfaType, code, timeBeforeTimeout) => {
|
|
221
|
+
const pageContent = await fc.sample(mfaPageContentArb(mfaType), 1)[0];
|
|
222
|
+
|
|
223
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
224
|
+
if (!challenge) return true;
|
|
225
|
+
|
|
226
|
+
await authTunnel.requestCode(challenge);
|
|
227
|
+
|
|
228
|
+
// Advance time but stay before timeout
|
|
229
|
+
vi.advanceTimersByTime(timeBeforeTimeout);
|
|
230
|
+
|
|
231
|
+
// PROPERTY: Code injection should succeed before timeout
|
|
232
|
+
const result = await authTunnel.injectCode(challenge, code);
|
|
233
|
+
expect(result.success).toBe(true);
|
|
234
|
+
expect(result.error).toBeUndefined();
|
|
235
|
+
|
|
236
|
+
// Challenge should be removed after successful injection
|
|
237
|
+
expect(authTunnel.getChallenge(challenge.id)).toBeNull();
|
|
238
|
+
|
|
239
|
+
authTunnel.clear();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
),
|
|
243
|
+
{ numRuns: 100 }
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// =============================================================================
|
|
249
|
+
// Unit Tests for MFA Detection
|
|
250
|
+
// =============================================================================
|
|
251
|
+
|
|
252
|
+
describe('Auth Tunnel Unit Tests', () => {
|
|
253
|
+
test('detects SMS MFA challenge', async () => {
|
|
254
|
+
const session: ShadowSession = {
|
|
255
|
+
id: 'shadow_test123',
|
|
256
|
+
domain: 'example.com',
|
|
257
|
+
status: 'active',
|
|
258
|
+
createdAt: Date.now(),
|
|
259
|
+
lastHeartbeat: Date.now(),
|
|
260
|
+
fingerprint: {
|
|
261
|
+
resolution: { width: 1920, height: 1080 },
|
|
262
|
+
userAgent: 'Mozilla/5.0',
|
|
263
|
+
fonts: ['Arial'],
|
|
264
|
+
gpuSignature: 'NVIDIA',
|
|
265
|
+
timezone: 'America/New_York',
|
|
266
|
+
language: 'en-US',
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const pageContent = `
|
|
271
|
+
<html>
|
|
272
|
+
<body>
|
|
273
|
+
<h1>Two-Factor Authentication</h1>
|
|
274
|
+
<p>We sent a verification code to your phone via SMS.</p>
|
|
275
|
+
<p>Enter the 6-digit code below:</p>
|
|
276
|
+
<input type="text" name="sms_code" placeholder="Enter code" />
|
|
277
|
+
<button type="submit">Verify</button>
|
|
278
|
+
</body>
|
|
279
|
+
</html>
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
283
|
+
|
|
284
|
+
expect(challenge).not.toBeNull();
|
|
285
|
+
expect(challenge?.type).toBe('sms');
|
|
286
|
+
expect(challenge?.sessionId).toBe(session.id);
|
|
287
|
+
expect(challenge?.expiresAt).toBeGreaterThan(Date.now());
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('detects authenticator MFA challenge', async () => {
|
|
291
|
+
const session: ShadowSession = {
|
|
292
|
+
id: 'shadow_test456',
|
|
293
|
+
domain: 'example.com',
|
|
294
|
+
status: 'active',
|
|
295
|
+
createdAt: Date.now(),
|
|
296
|
+
lastHeartbeat: Date.now(),
|
|
297
|
+
fingerprint: {
|
|
298
|
+
resolution: { width: 1920, height: 1080 },
|
|
299
|
+
userAgent: 'Mozilla/5.0',
|
|
300
|
+
fonts: ['Arial'],
|
|
301
|
+
gpuSignature: 'NVIDIA',
|
|
302
|
+
timezone: 'America/New_York',
|
|
303
|
+
language: 'en-US',
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const pageContent = `
|
|
308
|
+
<html>
|
|
309
|
+
<body>
|
|
310
|
+
<h1>Two-Factor Authentication</h1>
|
|
311
|
+
<p>Open your Google Authenticator app and enter the 6-digit code.</p>
|
|
312
|
+
<input type="text" name="totp_code" />
|
|
313
|
+
<button type="submit">Verify</button>
|
|
314
|
+
</body>
|
|
315
|
+
</html>
|
|
316
|
+
`;
|
|
317
|
+
|
|
318
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
319
|
+
|
|
320
|
+
expect(challenge).not.toBeNull();
|
|
321
|
+
expect(challenge?.type).toBe('authenticator');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('detects push notification MFA challenge', async () => {
|
|
325
|
+
const session: ShadowSession = {
|
|
326
|
+
id: 'shadow_test789',
|
|
327
|
+
domain: 'example.com',
|
|
328
|
+
status: 'active',
|
|
329
|
+
createdAt: Date.now(),
|
|
330
|
+
lastHeartbeat: Date.now(),
|
|
331
|
+
fingerprint: {
|
|
332
|
+
resolution: { width: 1920, height: 1080 },
|
|
333
|
+
userAgent: 'Mozilla/5.0',
|
|
334
|
+
fonts: ['Arial'],
|
|
335
|
+
gpuSignature: 'NVIDIA',
|
|
336
|
+
timezone: 'America/New_York',
|
|
337
|
+
language: 'en-US',
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const pageContent = `
|
|
342
|
+
<html>
|
|
343
|
+
<body>
|
|
344
|
+
<h1>Approve Login</h1>
|
|
345
|
+
<p>We sent a push notification to your device.</p>
|
|
346
|
+
<p>Tap to approve the login request.</p>
|
|
347
|
+
<p>Waiting for approval...</p>
|
|
348
|
+
</body>
|
|
349
|
+
</html>
|
|
350
|
+
`;
|
|
351
|
+
|
|
352
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
353
|
+
|
|
354
|
+
expect(challenge).not.toBeNull();
|
|
355
|
+
expect(challenge?.type).toBe('push');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('detects email MFA challenge', async () => {
|
|
359
|
+
const session: ShadowSession = {
|
|
360
|
+
id: 'shadow_testabc',
|
|
361
|
+
domain: 'example.com',
|
|
362
|
+
status: 'active',
|
|
363
|
+
createdAt: Date.now(),
|
|
364
|
+
lastHeartbeat: Date.now(),
|
|
365
|
+
fingerprint: {
|
|
366
|
+
resolution: { width: 1920, height: 1080 },
|
|
367
|
+
userAgent: 'Mozilla/5.0',
|
|
368
|
+
fonts: ['Arial'],
|
|
369
|
+
gpuSignature: 'NVIDIA',
|
|
370
|
+
timezone: 'America/New_York',
|
|
371
|
+
language: 'en-US',
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const pageContent = `
|
|
376
|
+
<html>
|
|
377
|
+
<body>
|
|
378
|
+
<h1>Email Verification</h1>
|
|
379
|
+
<p>We sent a verification email to your inbox.</p>
|
|
380
|
+
<p>Check your email and enter the email code below:</p>
|
|
381
|
+
<input type="text" name="email_code" />
|
|
382
|
+
<button type="submit">Verify</button>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|
|
385
|
+
`;
|
|
386
|
+
|
|
387
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
388
|
+
|
|
389
|
+
expect(challenge).not.toBeNull();
|
|
390
|
+
expect(challenge?.type).toBe('email');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('returns null for non-MFA pages', async () => {
|
|
394
|
+
const session: ShadowSession = {
|
|
395
|
+
id: 'shadow_testxyz',
|
|
396
|
+
domain: 'example.com',
|
|
397
|
+
status: 'active',
|
|
398
|
+
createdAt: Date.now(),
|
|
399
|
+
lastHeartbeat: Date.now(),
|
|
400
|
+
fingerprint: {
|
|
401
|
+
resolution: { width: 1920, height: 1080 },
|
|
402
|
+
userAgent: 'Mozilla/5.0',
|
|
403
|
+
fonts: ['Arial'],
|
|
404
|
+
gpuSignature: 'NVIDIA',
|
|
405
|
+
timezone: 'America/New_York',
|
|
406
|
+
language: 'en-US',
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const pageContent = `
|
|
411
|
+
<html>
|
|
412
|
+
<body>
|
|
413
|
+
<h1>Welcome to Our Website</h1>
|
|
414
|
+
<p>Browse our products and services.</p>
|
|
415
|
+
<a href="/products">View Products</a>
|
|
416
|
+
</body>
|
|
417
|
+
</html>
|
|
418
|
+
`;
|
|
419
|
+
|
|
420
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
421
|
+
|
|
422
|
+
expect(challenge).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('MFA timeout constant is 5 minutes', () => {
|
|
426
|
+
expect(MFA_TIMEOUT_MS).toBe(300000); // 5 minutes in milliseconds
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('challenge expiration is set correctly', async () => {
|
|
430
|
+
const session: ShadowSession = {
|
|
431
|
+
id: 'shadow_exptest',
|
|
432
|
+
domain: 'example.com',
|
|
433
|
+
status: 'active',
|
|
434
|
+
createdAt: Date.now(),
|
|
435
|
+
lastHeartbeat: Date.now(),
|
|
436
|
+
fingerprint: {
|
|
437
|
+
resolution: { width: 1920, height: 1080 },
|
|
438
|
+
userAgent: 'Mozilla/5.0',
|
|
439
|
+
fonts: ['Arial'],
|
|
440
|
+
gpuSignature: 'NVIDIA',
|
|
441
|
+
timezone: 'America/New_York',
|
|
442
|
+
language: 'en-US',
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const pageContent = 'Enter the verification code sent via SMS';
|
|
447
|
+
const beforeDetect = Date.now();
|
|
448
|
+
|
|
449
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
450
|
+
|
|
451
|
+
expect(challenge).not.toBeNull();
|
|
452
|
+
// Expiration should be approximately 5 minutes from now
|
|
453
|
+
expect(challenge!.expiresAt).toBeGreaterThanOrEqual(beforeDetect + MFA_TIMEOUT_MS);
|
|
454
|
+
expect(challenge!.expiresAt).toBeLessThanOrEqual(Date.now() + MFA_TIMEOUT_MS + 1000);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('invalid code format is rejected', async () => {
|
|
458
|
+
const session: ShadowSession = {
|
|
459
|
+
id: 'shadow_codetest',
|
|
460
|
+
domain: 'example.com',
|
|
461
|
+
status: 'active',
|
|
462
|
+
createdAt: Date.now(),
|
|
463
|
+
lastHeartbeat: Date.now(),
|
|
464
|
+
fingerprint: {
|
|
465
|
+
resolution: { width: 1920, height: 1080 },
|
|
466
|
+
userAgent: 'Mozilla/5.0',
|
|
467
|
+
fonts: ['Arial'],
|
|
468
|
+
gpuSignature: 'NVIDIA',
|
|
469
|
+
timezone: 'America/New_York',
|
|
470
|
+
language: 'en-US',
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const pageContent = 'Enter the verification code sent via SMS';
|
|
475
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
476
|
+
|
|
477
|
+
expect(challenge).not.toBeNull();
|
|
478
|
+
await authTunnel.requestCode(challenge!);
|
|
479
|
+
|
|
480
|
+
// Try invalid code formats
|
|
481
|
+
const invalidCodes = ['', ' ', 'abc', '12', '12345678901'];
|
|
482
|
+
|
|
483
|
+
for (const invalidCode of invalidCodes) {
|
|
484
|
+
const result = await authTunnel.injectCode(challenge!, invalidCode);
|
|
485
|
+
expect(result.success).toBe(false);
|
|
486
|
+
expect(result.error?.code).toBe('mfa_failed');
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('challenge can be cancelled', async () => {
|
|
491
|
+
const session: ShadowSession = {
|
|
492
|
+
id: 'shadow_canceltest',
|
|
493
|
+
domain: 'example.com',
|
|
494
|
+
status: 'active',
|
|
495
|
+
createdAt: Date.now(),
|
|
496
|
+
lastHeartbeat: Date.now(),
|
|
497
|
+
fingerprint: {
|
|
498
|
+
resolution: { width: 1920, height: 1080 },
|
|
499
|
+
userAgent: 'Mozilla/5.0',
|
|
500
|
+
fonts: ['Arial'],
|
|
501
|
+
gpuSignature: 'NVIDIA',
|
|
502
|
+
timezone: 'America/New_York',
|
|
503
|
+
language: 'en-US',
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const pageContent = 'Enter the verification code sent via SMS';
|
|
508
|
+
const challenge = await authTunnel.detectMFA(session, pageContent);
|
|
509
|
+
|
|
510
|
+
expect(challenge).not.toBeNull();
|
|
511
|
+
await authTunnel.requestCode(challenge!);
|
|
512
|
+
|
|
513
|
+
// Challenge should be pending
|
|
514
|
+
expect(authTunnel.getPendingChallengeCount()).toBe(1);
|
|
515
|
+
|
|
516
|
+
// Cancel the challenge
|
|
517
|
+
const cancelled = authTunnel.cancelChallenge(challenge!.id);
|
|
518
|
+
expect(cancelled).toBe(true);
|
|
519
|
+
|
|
520
|
+
// Challenge should be removed
|
|
521
|
+
expect(authTunnel.getPendingChallengeCount()).toBe(0);
|
|
522
|
+
expect(authTunnel.getChallenge(challenge!.id)).toBeNull();
|
|
523
|
+
});
|
|
524
|
+
});
|