prism-mcp-server 20.2.2 → 20.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1345 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ browse.py — HIPAA-Hardened Stealth Browser Automation CLI
4
+ ==========================================================
5
+ Local Playwright-based browser automation with multi-layer anti-detection.
6
+ Zero cloud dependency. Runs entirely on localhost.
7
+
8
+ STEALTH LAYERS:
9
+ Layer 1: playwright-stealth v2.x — JS evasion scripts (webdriver, chrome.runtime, etc.)
10
+ Layer 2: Realistic browser fingerprint — User-Agent, viewport, locale, timezone, WebGL
11
+ Layer 3: Behavioral stealth — human-like mouse, typing delays, scroll jitter
12
+ Layer 4: Chromium launch args — anti-automation flags, rendering mimicry
13
+ Layer 5: Network stealth — real browser headers, TLS fingerprint via Chromium
14
+ Layer 6: Persistent profiles — cookie jars survive restarts (looks like returning user)
15
+
16
+ SECURITY (HIPAA):
17
+ - FileVault (FDE) enforcement check
18
+ - Isolated persistent browser profiles (~/.browser_data/<profile>/)
19
+ - Audit logging (URLs + actions, never PHI content)
20
+ - --cleanup flag for secure screenshot wiping
21
+ - --sanitize flag to mask PHI patterns (SSN, MRN, phone) in output
22
+
23
+ MODES:
24
+ Single command: browse.py open https://example.com
25
+ Interactive: browse.py repl (keeps browser open, type commands)
26
+ Pipe/batch: echo "open https://..." | browse.py pipe
27
+ """
28
+
29
+ import argparse
30
+ import datetime
31
+ import hashlib
32
+ import io
33
+ import json
34
+ import os
35
+ import random
36
+ import re
37
+ import select
38
+ import signal
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ import time
43
+ from pathlib import Path
44
+ from urllib.parse import urlsplit, urlunsplit
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Constants
48
+ # ---------------------------------------------------------------------------
49
+ BROWSER_DATA_DIR = Path.home() / ".browser_data"
50
+ AUDIT_LOG_PATH = BROWSER_DATA_DIR / "audit.log"
51
+ DEFAULT_PROFILE = "default"
52
+ DEFAULT_TIMEOUT = 30000
53
+ DEFAULT_VIEWPORT = (1440, 900)
54
+ REPL_IDLE_TIMEOUT = 600 # 10 minutes — auto-close to prevent zombie Chromium
55
+ MAX_INIT_SCRIPT_BYTES = 256 * 1024
56
+ PROFILE_NAME_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
57
+ STEALTH_AVAILABLE = False
58
+
59
+ try:
60
+ from playwright_stealth import Stealth
61
+ STEALTH_AVAILABLE = True
62
+ except ImportError:
63
+ pass
64
+
65
+ # PHI sanitization patterns
66
+ PHI_PATTERNS = [
67
+ (re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[SSN-REDACTED]'),
68
+ (re.compile(r'\b\d{9}\b'), '[SSN-REDACTED]'),
69
+ (re.compile(r'\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b'), '[PHONE-REDACTED]'),
70
+ (re.compile(r'\bMRN[-:#]?\s*\d{4,12}\b', re.IGNORECASE), '[MRN-REDACTED]'),
71
+ (re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'), '[EMAIL-REDACTED]'),
72
+ ]
73
+
74
+
75
+ def validate_profile_name(profile: str) -> str:
76
+ """Keep persistent profiles inside BROWSER_DATA_DIR."""
77
+ if not PROFILE_NAME_PATTERN.fullmatch(profile):
78
+ raise ValueError(
79
+ "Profile names must be 1-64 characters using letters, numbers, '.', '_' or '-'."
80
+ )
81
+ return profile
82
+
83
+
84
+ def stable_profile_index(profile: str, count: int) -> int:
85
+ """Return a cross-process stable choice for profile-scoped settings."""
86
+ if count <= 0:
87
+ raise ValueError("count must be positive")
88
+ digest = hashlib.sha256(profile.encode('utf-8')).digest()
89
+ return int.from_bytes(digest[:8], 'big') % count
90
+
91
+
92
+ def is_local_test_url(url: str, allow_internal=False) -> bool:
93
+ """Allow only loopback HTTP(S) and self-contained browser URLs."""
94
+ try:
95
+ parsed = urlsplit(url)
96
+ except ValueError:
97
+ return False
98
+
99
+ if parsed.scheme in ('data', 'about'):
100
+ return True
101
+ if allow_internal and parsed.scheme == 'blob':
102
+ return is_local_test_url(parsed.path, allow_internal=False)
103
+ if parsed.scheme not in ('http', 'https'):
104
+ return False
105
+
106
+ hostname = (parsed.hostname or '').lower().rstrip('.')
107
+ return (
108
+ hostname in ('localhost', '127.0.0.1', '::1')
109
+ or hostname.endswith('.localhost')
110
+ )
111
+
112
+
113
+ def redact_audit_target(target: str) -> str:
114
+ """Remove credentials, query strings, fragments and data payloads from audit targets."""
115
+ if not target:
116
+ return ""
117
+ compact = str(target).replace('\n', ' ').replace('\r', ' ')
118
+ try:
119
+ parsed = urlsplit(compact)
120
+ except ValueError:
121
+ return sanitize_phi(compact)[:300]
122
+
123
+ if parsed.scheme == 'data':
124
+ return 'data:[redacted]'
125
+ if parsed.scheme in ('http', 'https'):
126
+ hostname = parsed.hostname or ''
127
+ if ':' in hostname and not hostname.startswith('['):
128
+ hostname = f'[{hostname}]'
129
+ netloc = hostname
130
+ try:
131
+ port = parsed.port
132
+ except ValueError:
133
+ port = None
134
+ if port:
135
+ netloc = f'{netloc}:{port}'
136
+ return urlunsplit((parsed.scheme, netloc, parsed.path or '/', '', ''))[:300]
137
+ return sanitize_phi(compact)[:300]
138
+
139
+
140
+ def _sanitize_audit_text(value: str) -> str:
141
+ """Sanitize arbitrary audit details, including embedded URLs."""
142
+ compact = str(value).replace('\n', ' ').replace('\r', ' ')
143
+ url_pattern = re.compile(r'(?i)(?:https?|data):[^\s|]+')
144
+ compact = url_pattern.sub(lambda match: redact_audit_target(match.group(0)), compact)
145
+ return sanitize_phi(compact)[:300]
146
+
147
+
148
+ def load_local_init_script(script_path: str) -> tuple[str, str]:
149
+ """Load and guard a custom pre-navigation script for local test pages only."""
150
+ candidate = Path(script_path).expanduser()
151
+ if candidate.is_symlink():
152
+ raise ValueError(f"Init script must not be a symlink: {script_path}")
153
+ try:
154
+ stat = candidate.stat()
155
+ except OSError as exc:
156
+ raise ValueError(f"Cannot read init script: {script_path}") from exc
157
+ if not candidate.is_file() or candidate.suffix.lower() not in ('.js', '.mjs'):
158
+ raise ValueError("Init scripts must be regular .js or .mjs files.")
159
+ if stat.st_size > MAX_INIT_SCRIPT_BYTES:
160
+ raise ValueError(f"Init scripts must be at most {MAX_INIT_SCRIPT_BYTES} bytes.")
161
+ try:
162
+ source = candidate.read_text(encoding='utf-8')
163
+ except UnicodeDecodeError as exc:
164
+ raise ValueError("Init scripts must be UTF-8 text.") from exc
165
+
166
+ digest = hashlib.sha256(source.encode('utf-8')).hexdigest()[:12]
167
+ guarded = f"""
168
+ (() => {{
169
+ const host = location.hostname.toLowerCase().replace(/\\.$/, '');
170
+ const allowed = location.protocol === 'data:' || location.protocol === 'about:' ||
171
+ host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost');
172
+ if (!allowed) return;
173
+ {source}
174
+ }})();
175
+ """
176
+ return guarded, digest
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Stealth Configuration — the core anti-detection engine
180
+ # ---------------------------------------------------------------------------
181
+ # Realistic User-Agent strings for macOS Chrome (rotated per-profile)
182
+ STEALTH_USER_AGENTS = [
183
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
184
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
185
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
186
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
187
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Safari/537.36",
188
+ ]
189
+
190
+ # Chromium args that reduce automation fingerprint
191
+ STEALTH_CHROMIUM_ARGS = [
192
+ '--disable-blink-features=AutomationControlled', # Kill navigator.webdriver
193
+ '--disable-features=IsolateOrigins,site-per-process', # Reduce iframe isolation fingerprint
194
+ '--disable-site-isolation-trials',
195
+ '--disable-features=AutomationControlled',
196
+ '--no-first-run',
197
+ '--no-default-browser-check',
198
+ '--disable-infobars', # Remove "Chrome is controlled" bar
199
+ '--disable-background-timer-throttling',
200
+ '--disable-backgrounding-occluded-windows',
201
+ '--disable-renderer-backgrounding',
202
+ '--disable-component-update',
203
+ '--disable-dev-shm-usage',
204
+ '--disable-hang-monitor',
205
+ '--disable-popup-blocking',
206
+ '--disable-prompt-on-repost',
207
+ '--disable-sync',
208
+ '--metrics-recording-only',
209
+ '--no-service-autorun',
210
+ '--password-store=basic',
211
+ '--use-mock-keychain',
212
+ '--enable-features=NetworkService,NetworkServiceInProcess',
213
+ '--force-color-profile=srgb',
214
+ '--disable-domain-reliability',
215
+ '--disable-client-side-phishing-detection',
216
+ '--lang=en-US',
217
+ ]
218
+
219
+ # Deep stealth: JavaScript to inject BEFORE any page scripts run
220
+ # These patches survive beyond what playwright-stealth provides
221
+ DEEP_STEALTH_INIT_SCRIPT = """
222
+ // === LAYER 2: Deep Fingerprint Evasion ===
223
+
224
+ // 1. Override navigator.webdriver (belt-and-suspenders with playwright-stealth)
225
+ Object.defineProperty(navigator, 'webdriver', {
226
+ get: () => undefined,
227
+ configurable: true
228
+ });
229
+
230
+ // 2. Mock chrome.runtime to look like a real browser
231
+ if (!window.chrome) { window.chrome = {}; }
232
+ if (!window.chrome.runtime) {
233
+ window.chrome.runtime = {
234
+ connect: function() { return { onMessage: { addListener: function(){} }, postMessage: function(){} }; },
235
+ sendMessage: function() {},
236
+ id: undefined, // Real Chrome has undefined id in non-extension context
237
+ onMessage: { addListener: function(){}, removeListener: function(){} },
238
+ onConnect: { addListener: function(){}, removeListener: function(){} },
239
+ };
240
+ }
241
+
242
+ // 3. Fix chrome.csi (Chrome Session Information)
243
+ if (!window.chrome.csi) {
244
+ window.chrome.csi = function() {
245
+ return {
246
+ startE: Date.now(),
247
+ onloadT: Date.now(),
248
+ pageT: Math.random() * 1000 + 200,
249
+ tran: 15
250
+ };
251
+ };
252
+ }
253
+
254
+ // 4. Fix chrome.loadTimes
255
+ if (!window.chrome.loadTimes) {
256
+ window.chrome.loadTimes = function() {
257
+ return {
258
+ commitLoadTime: Date.now() / 1000,
259
+ connectionInfo: "h2",
260
+ finishDocumentLoadTime: Date.now() / 1000 + Math.random(),
261
+ finishLoadTime: Date.now() / 1000 + Math.random(),
262
+ firstPaintAfterLoadTime: 0,
263
+ firstPaintTime: Date.now() / 1000 + Math.random() * 0.5,
264
+ navigationType: "Other",
265
+ npnNegotiatedProtocol: "h2",
266
+ requestTime: Date.now() / 1000 - Math.random(),
267
+ startLoadTime: Date.now() / 1000 - Math.random(),
268
+ wasAlternateProtocolAvailable: false,
269
+ wasFetchedViaSpdy: true,
270
+ wasNpnNegotiated: true,
271
+ };
272
+ };
273
+ }
274
+
275
+ // 5. Fix navigator.plugins (headless has 0 plugins — dead giveaway)
276
+ if (navigator.plugins.length === 0) {
277
+ Object.defineProperty(navigator, 'plugins', {
278
+ get: () => {
279
+ const plugins = [
280
+ { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" },
281
+ { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "" },
282
+ { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "" },
283
+ { name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "" },
284
+ { name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "" },
285
+ ];
286
+ plugins.item = (i) => plugins[i] || null;
287
+ plugins.namedItem = (name) => plugins.find(p => p.name === name) || null;
288
+ plugins.refresh = () => {};
289
+ return plugins;
290
+ },
291
+ configurable: true
292
+ });
293
+ }
294
+
295
+ // 6. Fix navigator.mimeTypes
296
+ if (navigator.mimeTypes.length === 0) {
297
+ Object.defineProperty(navigator, 'mimeTypes', {
298
+ get: () => {
299
+ const mimes = [
300
+ { type: "application/pdf", suffixes: "pdf", description: "Portable Document Format" },
301
+ { type: "text/pdf", suffixes: "pdf", description: "" },
302
+ ];
303
+ mimes.item = (i) => mimes[i] || null;
304
+ mimes.namedItem = (name) => mimes.find(m => m.type === name) || null;
305
+ return mimes;
306
+ },
307
+ configurable: true
308
+ });
309
+ }
310
+
311
+ // 7. Fix permissions API (Notification.permission detection)
312
+ const originalQuery = window.navigator.permissions?.query;
313
+ if (originalQuery) {
314
+ window.navigator.permissions.query = (parameters) => (
315
+ parameters.name === 'notifications' ?
316
+ Promise.resolve({ state: Notification.permission }) :
317
+ originalQuery(parameters)
318
+ );
319
+ }
320
+
321
+ // 8. Prevent iframe detection (contentWindow detection)
322
+ const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
323
+ Object.getOwnPropertyDescriptor = function(obj, prop) {
324
+ if (prop === 'contentWindow' || prop === 'contentDocument') {
325
+ return undefined;
326
+ }
327
+ return originalGetOwnPropertyDescriptor(obj, prop);
328
+ };
329
+
330
+ // 9. Fix WebGL vendor/renderer (headless shows "Google SwiftShader")
331
+ const getParameter = WebGLRenderingContext.prototype.getParameter;
332
+ WebGLRenderingContext.prototype.getParameter = function(parameter) {
333
+ // UNMASKED_VENDOR_WEBGL
334
+ if (parameter === 37445) {
335
+ return 'Google Inc. (Apple)';
336
+ }
337
+ // UNMASKED_RENDERER_WEBGL
338
+ if (parameter === 37446) {
339
+ return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)';
340
+ }
341
+ return getParameter.call(this, parameter);
342
+ };
343
+
344
+ // Also patch WebGL2
345
+ if (typeof WebGL2RenderingContext !== 'undefined') {
346
+ const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
347
+ WebGL2RenderingContext.prototype.getParameter = function(parameter) {
348
+ if (parameter === 37445) return 'Google Inc. (Apple)';
349
+ if (parameter === 37446) return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)';
350
+ return getParameter2.call(this, parameter);
351
+ };
352
+ }
353
+
354
+ // 10. Fix navigator.connection (missing in automation)
355
+ if (!navigator.connection) {
356
+ Object.defineProperty(navigator, 'connection', {
357
+ get: () => ({
358
+ downlink: 10,
359
+ effectiveType: '4g',
360
+ rtt: 50,
361
+ saveData: false,
362
+ onchange: null,
363
+ }),
364
+ configurable: true
365
+ });
366
+ }
367
+
368
+ // 11. Fix window.outerHeight/outerWidth (headless often has 0)
369
+ if (window.outerHeight === 0) {
370
+ Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 85 });
371
+ }
372
+ if (window.outerWidth === 0) {
373
+ Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth });
374
+ }
375
+
376
+ // 12. Prevent toString() detection of overridden functions
377
+ // Bot detectors call .toString() on navigator methods to check for "[native code]"
378
+ const nativeToString = Function.prototype.toString;
379
+ const nativeFunctions = new Map();
380
+ const handler = {
381
+ apply: function(target, ctx, args) {
382
+ if (ctx === navigator.permissions.query) {
383
+ return 'function query() { [native code] }';
384
+ }
385
+ return nativeToString.apply(ctx, args);
386
+ }
387
+ };
388
+ // Only wrap if Proxy is available
389
+ if (typeof Proxy !== 'undefined') {
390
+ try {
391
+ Function.prototype.toString = new Proxy(nativeToString, handler);
392
+ } catch(e) {}
393
+ }
394
+ """
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Security checks
399
+ # ---------------------------------------------------------------------------
400
+ def check_filevault():
401
+ """Verify FileVault (Full Disk Encryption) is enabled."""
402
+ try:
403
+ result = subprocess.run(['fdesetup', 'status'], capture_output=True, text=True, timeout=5)
404
+ if 'FileVault is On' in result.stdout:
405
+ return True
406
+ print("⛔ HIPAA VIOLATION: FileVault is OFF.", file=sys.stderr)
407
+ return False
408
+ except Exception:
409
+ print("⚠️ Cannot verify disk encryption.", file=sys.stderr)
410
+ return True
411
+
412
+
413
+ def sanitize_phi(text: str) -> str:
414
+ """Mask PHI patterns in text output."""
415
+ for pattern, replacement in PHI_PATTERNS:
416
+ text = pattern.sub(replacement, text)
417
+ return text
418
+
419
+
420
+ def secure_delete(filepath: str):
421
+ """Securely wipe a file."""
422
+ path = Path(filepath)
423
+ if not path.exists():
424
+ return
425
+ try:
426
+ size = path.stat().st_size
427
+ with open(path, 'wb') as f:
428
+ f.write(os.urandom(size))
429
+ f.flush()
430
+ os.fsync(f.fileno())
431
+ path.unlink()
432
+ except Exception as e:
433
+ print(f"⚠️ Secure delete failed: {e}", file=sys.stderr)
434
+ try:
435
+ path.unlink()
436
+ except Exception:
437
+ pass
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Audit logging
442
+ # ---------------------------------------------------------------------------
443
+ def _ensure_audit_log_permissions():
444
+ """Create audit log with strict permissions (chmod 600) so other processes can't read it."""
445
+ AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
446
+ try:
447
+ os.chmod(AUDIT_LOG_PATH.parent, 0o700)
448
+ except Exception:
449
+ pass
450
+ if AUDIT_LOG_PATH.is_symlink():
451
+ raise RuntimeError(f"Refusing symlinked audit log: {AUDIT_LOG_PATH}")
452
+ if not AUDIT_LOG_PATH.exists():
453
+ AUDIT_LOG_PATH.touch(mode=0o600)
454
+ else:
455
+ try:
456
+ os.chmod(AUDIT_LOG_PATH, 0o600)
457
+ except Exception:
458
+ pass
459
+
460
+
461
+ def audit_log(action: str, target: str = "", details: str = ""):
462
+ """Write audit log entry. Records WHAT and WHERE, never content."""
463
+ _ensure_audit_log_permissions()
464
+ ts = datetime.datetime.now().isoformat()
465
+ safe_target = redact_audit_target(target)
466
+ safe_details = _sanitize_audit_text(details) if details else ""
467
+ try:
468
+ with open(AUDIT_LOG_PATH, 'a', encoding='utf-8') as f:
469
+ f.write(f"{ts} | {action} | {safe_target} | {safe_details}\n")
470
+ except Exception:
471
+ pass
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Behavioral stealth — human-like interaction helpers
476
+ # ---------------------------------------------------------------------------
477
+ def human_delay(min_ms=50, max_ms=200):
478
+ """Random human-like delay between actions."""
479
+ time.sleep(random.uniform(min_ms / 1000, max_ms / 1000))
480
+
481
+
482
+ def human_type(page, text, selector=None):
483
+ """Type like a human with variable delays between keystrokes."""
484
+ if selector:
485
+ page.click(selector)
486
+ human_delay(100, 300)
487
+
488
+ for char in text:
489
+ page.keyboard.type(char, delay=random.randint(30, 120))
490
+ # Occasional longer pause (like thinking)
491
+ if random.random() < 0.05:
492
+ human_delay(200, 500)
493
+
494
+
495
+ def human_scroll(page, direction="down", steps=3):
496
+ """Scroll like a human — variable speed, small increments."""
497
+ for _ in range(steps):
498
+ delta = random.randint(150, 400) * (1 if direction == "down" else -1)
499
+ page.mouse.wheel(0, delta)
500
+ human_delay(100, 400)
501
+
502
+
503
+ def human_mouse_move(page, x, y):
504
+ """Move mouse with slight curve (not teleportation)."""
505
+ # Get current approximate position
506
+ page.mouse.move(x + random.randint(-5, 5), y + random.randint(-5, 5))
507
+ human_delay(30, 80)
508
+ page.mouse.move(x, y)
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Browser Session with Stealth
513
+ # ---------------------------------------------------------------------------
514
+ class StealthBrowserSession:
515
+ """Manages a stealth Playwright browser session."""
516
+
517
+ def __init__(self, profile=DEFAULT_PROFILE, headless=False,
518
+ timeout=DEFAULT_TIMEOUT, viewport=DEFAULT_VIEWPORT,
519
+ stealth_level="full", local_only=False,
520
+ init_script_paths=None):
521
+ self.profile = validate_profile_name(profile)
522
+ self.headless = headless
523
+ self.timeout = timeout
524
+ self.viewport = viewport
525
+ self.stealth_level = stealth_level # "full", "light", "none"
526
+ self.local_only = local_only
527
+ self.profile_dir = BROWSER_DATA_DIR / self.profile
528
+ self._playwright = None
529
+ self._context = None
530
+ self._page = None
531
+ script_paths = list(init_script_paths or [])
532
+ if script_paths and not self.local_only:
533
+ raise ValueError("Custom init scripts require --local-only.")
534
+ self._init_scripts = [load_local_init_script(path) for path in script_paths]
535
+ # Pick a consistent UA for this profile
536
+ ua_idx = stable_profile_index(self.profile, len(STEALTH_USER_AGENTS))
537
+ self._user_agent = STEALTH_USER_AGENTS[ua_idx]
538
+
539
+ def __enter__(self):
540
+ self.start()
541
+ return self
542
+
543
+ def __exit__(self, *args):
544
+ self.stop()
545
+
546
+ def start(self):
547
+ from playwright.sync_api import sync_playwright
548
+ self.profile_dir.mkdir(parents=True, exist_ok=True)
549
+ self._playwright = sync_playwright().start()
550
+
551
+ # Validate UA ↔ WebGL fingerprint consistency
552
+ # If UA says "Mac OS X 14_5" then WebGL must say Apple/Metal, not SwiftShader
553
+ self._validate_fingerprint_consistency()
554
+
555
+ # Build launch args
556
+ launch_args = list(STEALTH_CHROMIUM_ARGS) if self.stealth_level != "none" else [
557
+ '--no-first-run', '--no-default-browser-check'
558
+ ]
559
+
560
+ # Launch persistent context
561
+ self._context = self._playwright.chromium.launch_persistent_context(
562
+ user_data_dir=str(self.profile_dir),
563
+ headless=self.headless,
564
+ viewport={'width': self.viewport[0], 'height': self.viewport[1]},
565
+ user_agent=self._user_agent,
566
+ locale='en-US',
567
+ timezone_id='America/New_York',
568
+ geolocation={'latitude': 40.7128, 'longitude': -74.0060}, # NYC
569
+ permissions=['geolocation'],
570
+ color_scheme='light',
571
+ args=launch_args,
572
+ ignore_default_args=['--enable-automation'], # Critical: removes automation flag
573
+ )
574
+ self._context.set_default_timeout(self.timeout)
575
+
576
+ # Apply stealth evasions
577
+ if self.stealth_level != "none":
578
+ self._apply_stealth()
579
+ elif self.local_only:
580
+ self._apply_local_only_guard()
581
+
582
+ for source, digest in self._init_scripts:
583
+ self._context.add_init_script(source)
584
+ audit_log("init_script", f"sha256={digest}", "scope=local-only")
585
+
586
+ # Get or create page
587
+ if self._context.pages:
588
+ self._page = self._context.pages[0]
589
+ else:
590
+ self._page = self._context.new_page()
591
+
592
+ audit_log("session_start", f"profile={self.profile}",
593
+ f"stealth={self.stealth_level},headless={self.headless},local_only={self.local_only}")
594
+
595
+ def _apply_stealth(self):
596
+ """Apply multi-layer stealth evasions."""
597
+ # Layer 1: playwright-stealth library (if available)
598
+ if STEALTH_AVAILABLE and self.stealth_level == "full":
599
+ try:
600
+ stealth = Stealth(
601
+ # Configure specific evasions
602
+ navigator_webdriver=True,
603
+ navigator_plugins=True,
604
+ navigator_permissions=True,
605
+ navigator_languages_override=("en-US", "en"),
606
+ webgl_vendor="Google Inc. (Apple)",
607
+ webgl_renderer="ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Max, Unspecified Version)",
608
+ init_scripts_only=True, # Required for persistent context
609
+ )
610
+ stealth.apply_stealth_sync(self._context)
611
+ audit_log("stealth", "playwright-stealth", "applied_v2")
612
+ except Exception as e:
613
+ audit_log("stealth", "playwright-stealth", f"error={e}")
614
+
615
+ # Layer 2: Deep JS init script (always applied for full/light)
616
+ try:
617
+ self._context.add_init_script(DEEP_STEALTH_INIT_SCRIPT)
618
+ audit_log("stealth", "deep_init_script", "injected")
619
+ except Exception as e:
620
+ audit_log("stealth", "deep_init_script", f"error={e}")
621
+
622
+ # Layer 3: Route handler to fix headers
623
+ def fix_headers(route, request):
624
+ """Ensure realistic HTTP headers on every request."""
625
+ if self.local_only and not is_local_test_url(request.url, allow_internal=True):
626
+ audit_log("request_blocked", request.url, "policy=local-only")
627
+ route.abort("blockedbyclient")
628
+ return
629
+ headers = {
630
+ **request.headers,
631
+ 'sec-ch-ua': '"Chromium";v="131", "Google Chrome";v="131", "Not_A_Brand";v="24"',
632
+ 'sec-ch-ua-mobile': '?0',
633
+ 'sec-ch-ua-platform': '"macOS"',
634
+ 'sec-fetch-dest': request.headers.get('sec-fetch-dest', 'document'),
635
+ 'sec-fetch-mode': request.headers.get('sec-fetch-mode', 'navigate'),
636
+ 'sec-fetch-site': request.headers.get('sec-fetch-site', 'none'),
637
+ 'sec-fetch-user': '?1',
638
+ 'upgrade-insecure-requests': '1',
639
+ }
640
+ # Remove automation-specific headers
641
+ headers.pop('sec-ch-ua-full-version-list', None)
642
+ route.continue_(headers=headers)
643
+
644
+ try:
645
+ self._context.route("**/*", fix_headers)
646
+ audit_log("stealth", "header_fix", "routing_active")
647
+ except Exception as e:
648
+ audit_log("stealth", "header_fix", f"error={e}")
649
+ if self.local_only:
650
+ raise RuntimeError("Cannot enforce --local-only network isolation.") from e
651
+
652
+ def _apply_local_only_guard(self):
653
+ """Block non-loopback network requests when stealth routing is disabled."""
654
+ def guard(route, request):
655
+ if is_local_test_url(request.url, allow_internal=True):
656
+ route.continue_()
657
+ return
658
+ audit_log("request_blocked", request.url, "policy=local-only")
659
+ route.abort("blockedbyclient")
660
+
661
+ self._context.route("**/*", guard)
662
+
663
+ def _validate_fingerprint_consistency(self):
664
+ """
665
+ Validate that User-Agent and WebGL renderer fingerprints are consistent.
666
+ Enterprise WAFs (Cloudflare Turnstile) flag mismatches between UA and WebGL.
667
+ E.g., UA says 'Mac OS X' but WebGL says 'Google SwiftShader' = flagged.
668
+ """
669
+ ua = self._user_agent.lower()
670
+ # Ensure macOS UA gets Apple/Metal WebGL (not SwiftShader)
671
+ if 'macintosh' in ua or 'mac os x' in ua:
672
+ # Our DEEP_STEALTH_INIT_SCRIPT already sets Apple M3 Max — consistent
673
+ audit_log("fingerprint_check", "ua_webgl", "consistent=true,platform=macos")
674
+ elif 'windows' in ua:
675
+ # Would need to adjust WebGL to Intel/NVIDIA — not our case
676
+ audit_log("fingerprint_check", "ua_webgl", "warning=windows_ua_with_macos_webgl")
677
+ else:
678
+ audit_log("fingerprint_check", "ua_webgl", "consistent=unknown")
679
+
680
+ def stop(self):
681
+ if self._context:
682
+ try:
683
+ self._context.close()
684
+ except Exception:
685
+ pass
686
+ if self._playwright:
687
+ try:
688
+ self._playwright.stop()
689
+ except Exception:
690
+ pass
691
+ audit_log("session_stop", f"profile={self.profile}")
692
+
693
+ @property
694
+ def page(self):
695
+ return self._page
696
+
697
+ @property
698
+ def context(self):
699
+ return self._context
700
+
701
+
702
+ # ---------------------------------------------------------------------------
703
+ # Commands
704
+ # ---------------------------------------------------------------------------
705
+ def cmd_open(session, url):
706
+ """Navigate to URL."""
707
+ if session.local_only and not is_local_test_url(url):
708
+ raise ValueError("--local-only permits only loopback HTTP(S), data:, and about: URLs.")
709
+
710
+ session.page.goto(url, wait_until='domcontentloaded')
711
+ try:
712
+ session.page.wait_for_load_state('networkidle', timeout=15000)
713
+ except Exception as exc:
714
+ # A continuously-polling page can be usable without reaching networkidle.
715
+ if type(exc).__name__ != 'TimeoutError':
716
+ raise
717
+ title = session.page.title()
718
+ current_url = session.page.url
719
+ audit_log("open", url, f"title_chars={len(title)}")
720
+ return {"status": "ok", "url": current_url, "title": title}
721
+
722
+
723
+ def cmd_screenshot(session, output=None, cleanup=False):
724
+ """Take screenshot. When cleanup=True, writes to /tmp (avoids APFS CoW residue)."""
725
+ if not output:
726
+ ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
727
+ if cleanup:
728
+ # APFS Copy-on-Write: overwriting doesn't guarantee physical block wipe.
729
+ # Use /tmp (may be RAM-backed) for ephemeral screenshots.
730
+ output = os.path.join(tempfile.gettempdir(), f"browse_ss_{ts}.png")
731
+ else:
732
+ output = f"screenshot_{ts}.png"
733
+ path = Path(output).resolve()
734
+ session.page.screenshot(path=str(path), full_page=True)
735
+ size = path.stat().st_size
736
+ audit_log("screenshot", str(path), f"size={size},ephemeral={cleanup}")
737
+ result = {"status": "ok", "path": str(path), "size_bytes": size}
738
+ if cleanup:
739
+ result["cleanup_path"] = str(path)
740
+ result["ephemeral"] = True
741
+ return result
742
+
743
+
744
+ def cmd_read_dom(session, selector=None, sanitize=False):
745
+ """Read DOM text content."""
746
+ if selector:
747
+ try:
748
+ el = session.page.wait_for_selector(selector, timeout=10000)
749
+ text = el.inner_text() if el else ""
750
+ except Exception as e:
751
+ return {"status": "error", "message": str(e)}
752
+ else:
753
+ text = session.page.inner_text('body')
754
+ if sanitize:
755
+ text = sanitize_phi(text)
756
+ audit_log("read_dom", selector or "body", f"chars={len(text)}")
757
+ return {"status": "ok", "text": text}
758
+
759
+
760
+ def cmd_read_page(session, sanitize=False):
761
+ """Read full page as structured text."""
762
+ title = session.page.title()
763
+ url = session.page.url
764
+ body = session.page.inner_text('body')
765
+ if sanitize:
766
+ body = sanitize_phi(body)
767
+ title = sanitize_phi(title)
768
+ audit_log("read_page", url, f"chars={len(body)}")
769
+ return {"status": "ok", "url": url, "title": title, "text": body}
770
+
771
+
772
+ def cmd_click(session, selector):
773
+ """Click with human-like behavior."""
774
+ human_delay(100, 300)
775
+ session.page.click(selector)
776
+ audit_log("click", selector)
777
+ return {"status": "ok", "action": "click", "selector": selector}
778
+
779
+
780
+ def cmd_type_text(session, selector, text, human=True):
781
+ """Type text into element."""
782
+ if human:
783
+ human_type(session.page, text, selector)
784
+ else:
785
+ session.page.fill(selector, text)
786
+ audit_log("type", selector, f"chars={len(text)}")
787
+ return {"status": "ok", "action": "type", "selector": selector, "chars": len(text)}
788
+
789
+
790
+ def cmd_press(session, key):
791
+ """Press one Playwright keyboard key or key combination."""
792
+ if not key:
793
+ raise ValueError("Usage: press <key>")
794
+ session.page.keyboard.press(key)
795
+ audit_log("press", "keyboard", f"key={key}")
796
+ return {"status": "ok", "action": "press", "key": key}
797
+
798
+
799
+ def cmd_scroll(session, direction="down", amount=3):
800
+ """Scroll page with human-like behavior."""
801
+ human_scroll(session.page, direction, amount)
802
+ audit_log("scroll", direction, f"steps={amount}")
803
+ return {"status": "ok", "action": "scroll", "direction": direction}
804
+
805
+
806
+ def cmd_wait_for(session, selector, timeout=None):
807
+ """Wait for element (Playwright auto-waiting)."""
808
+ t = timeout or session.timeout
809
+ try:
810
+ el = session.page.wait_for_selector(selector, timeout=t, state='visible')
811
+ preview = el.inner_text()[:100] if el else ""
812
+ audit_log("wait_for", selector, "found=true")
813
+ return {"status": "ok", "found": True, "preview": preview}
814
+ except Exception as e:
815
+ audit_log("wait_for", selector, f"found=false")
816
+ return {"status": "timeout", "found": False, "error": str(e)}
817
+
818
+
819
+ def cmd_eval(session, js_code, sanitize=False):
820
+ """Evaluate JavaScript."""
821
+ result = session.page.evaluate(js_code)
822
+ output = str(result)
823
+ if sanitize:
824
+ output = sanitize_phi(output)
825
+ audit_log("eval", "js", f"chars={len(output)}")
826
+ return {"status": "ok", "result": output}
827
+
828
+
829
+ # Google Docs commands
830
+ def cmd_gdoc_read(session, sanitize=False):
831
+ """Read Google Doc content using keyboard shortcuts."""
832
+ page = session.page
833
+ if 'docs.google.com' not in page.url:
834
+ return {"status": "error", "message": "Not on a Google Doc page."}
835
+
836
+ try:
837
+ page.wait_for_selector('.kix-appview-editor', timeout=10000)
838
+ except Exception:
839
+ page.wait_for_load_state('networkidle')
840
+
841
+ # Click into doc body
842
+ try:
843
+ page.click('.kix-appview-editor', timeout=5000)
844
+ except Exception:
845
+ try:
846
+ page.click('.kix-page', timeout=5000)
847
+ except Exception:
848
+ pass
849
+
850
+ human_delay(300, 600)
851
+ mod = 'Meta' if sys.platform == 'darwin' else 'Control'
852
+
853
+ # Select all + copy
854
+ page.keyboard.press(f'{mod}+a')
855
+ human_delay(200, 400)
856
+ page.keyboard.press(f'{mod}+c')
857
+ human_delay(300, 600)
858
+
859
+ # Try clipboard
860
+ text = None
861
+ try:
862
+ text = page.evaluate('''async () => {
863
+ try { return await navigator.clipboard.readText(); }
864
+ catch(e) { return null; }
865
+ }''')
866
+ except Exception:
867
+ pass
868
+
869
+ # Fallback: DOM extraction
870
+ if not text:
871
+ try:
872
+ text = page.evaluate('''() => {
873
+ const nodes = document.querySelectorAll('.kix-lineview .kix-wordhtmlgenerator-word-node');
874
+ if (nodes.length > 0) return Array.from(nodes).map(n => n.textContent).join('');
875
+ const ed = document.querySelector('.kix-appview-editor');
876
+ return ed ? ed.innerText : document.body.innerText;
877
+ }''')
878
+ except Exception:
879
+ text = page.inner_text('body')
880
+
881
+ page.keyboard.press('End') # Deselect
882
+
883
+ if sanitize and text:
884
+ text = sanitize_phi(text)
885
+
886
+ audit_log("gdoc_read", page.url, f"chars={len(text) if text else 0}")
887
+ return {"status": "ok", "text": text or ""}
888
+
889
+
890
+ def cmd_gdoc_type(session, text):
891
+ """Type text at cursor in Google Doc with human-like delays."""
892
+ page = session.page
893
+ if 'docs.google.com' not in page.url:
894
+ return {"status": "error", "message": "Not on a Google Doc page."}
895
+ human_type(page, text)
896
+ audit_log("gdoc_type", page.url, f"chars={len(text)}")
897
+ return {"status": "ok", "action": "gdoc_type", "chars": len(text)}
898
+
899
+
900
+ def cmd_gdoc_find(session, search_text):
901
+ """Find text in Google Doc using Ctrl+F."""
902
+ page = session.page
903
+ if 'docs.google.com' not in page.url:
904
+ return {"status": "error", "message": "Not on a Google Doc page."}
905
+ mod = 'Meta' if sys.platform == 'darwin' else 'Control'
906
+ page.keyboard.press(f'{mod}+f')
907
+ human_delay(300, 600)
908
+ human_type(page, search_text)
909
+ human_delay(300, 500)
910
+ page.keyboard.press('Enter')
911
+ human_delay(300, 500)
912
+ page.keyboard.press('Escape')
913
+ human_delay(200, 400)
914
+ audit_log("gdoc_find", page.url, f"query_len={len(search_text)}")
915
+ return {"status": "ok", "action": "gdoc_find"}
916
+
917
+
918
+ def cmd_stealth_test(session):
919
+ """Run bot detection tests and report results."""
920
+ page = session.page
921
+ page.goto('https://bot.sannysoft.com/', wait_until='networkidle')
922
+ human_delay(2000, 3000)
923
+
924
+ # Extract test results
925
+ results = page.evaluate('''() => {
926
+ const rows = document.querySelectorAll('table tr');
927
+ const results = {};
928
+ rows.forEach(row => {
929
+ const cells = row.querySelectorAll('td');
930
+ if (cells.length >= 2) {
931
+ const test = cells[0].textContent.trim();
932
+ const cell = cells[1];
933
+ const passed = cell.classList.contains('result-passed') ||
934
+ cell.style.backgroundColor === 'rgb(144, 238, 144)' ||
935
+ cell.textContent.includes('missing') === false;
936
+ results[test] = {
937
+ value: cells[1].textContent.trim(),
938
+ passed: !cell.classList.contains('result-failed')
939
+ };
940
+ }
941
+ });
942
+ return results;
943
+ }''')
944
+
945
+ # Also check specific known indicators
946
+ webdriver = page.evaluate('navigator.webdriver')
947
+ chrome_exists = page.evaluate('!!window.chrome')
948
+ plugins_count = page.evaluate('navigator.plugins.length')
949
+
950
+ audit_log("stealth_test", "bot.sannysoft.com", f"webdriver={webdriver}")
951
+
952
+ return {
953
+ "status": "ok",
954
+ "webdriver": webdriver,
955
+ "chrome_exists": chrome_exists,
956
+ "plugins_count": plugins_count,
957
+ "detailed_results": results
958
+ }
959
+
960
+
961
+ # ---------------------------------------------------------------------------
962
+ # REPL mode — interactive session with idle timeout & error resilience
963
+ # ---------------------------------------------------------------------------
964
+ class _IdleTimeoutError(Exception):
965
+ pass
966
+
967
+
968
+ def _alarm_handler(signum, frame):
969
+ raise _IdleTimeoutError()
970
+
971
+
972
+ def _read_input_with_timeout(prompt, timeout_sec):
973
+ """Read input with idle timeout. Uses SIGALRM on Unix."""
974
+ if hasattr(signal, 'SIGALRM'):
975
+ old_handler = signal.signal(signal.SIGALRM, _alarm_handler)
976
+ signal.alarm(timeout_sec)
977
+ try:
978
+ line = input(prompt)
979
+ signal.alarm(0) # Cancel alarm
980
+ return line
981
+ except _IdleTimeoutError:
982
+ return None # Timeout
983
+ finally:
984
+ signal.signal(signal.SIGALRM, old_handler)
985
+ else:
986
+ # Fallback for systems without SIGALRM (shouldn't happen on Mac)
987
+ return input(prompt)
988
+
989
+
990
+ def run_repl(session, sanitize=False):
991
+ """
992
+ Interactive REPL — keeps browser open between commands.
993
+
994
+ Features:
995
+ - 10-min idle timeout: auto-closes browser to prevent zombie Chromium
996
+ - Error resilience: exceptions are caught and returned as JSON, browser stays alive
997
+ - Structured JSON output on stdout for agent parsing
998
+ """
999
+ print(json.dumps({
1000
+ "status": "ok",
1001
+ "action": "repl_start",
1002
+ "profile": session.profile,
1003
+ "stealth": session.stealth_level,
1004
+ "stealth_lib": "playwright-stealth-v2" if STEALTH_AVAILABLE else "js-only",
1005
+ "idle_timeout_sec": REPL_IDLE_TIMEOUT,
1006
+ }))
1007
+ sys.stdout.flush()
1008
+
1009
+ while True:
1010
+ try:
1011
+ line = _read_input_with_timeout("browse> ", REPL_IDLE_TIMEOUT)
1012
+ except (EOFError, KeyboardInterrupt):
1013
+ print(json.dumps({"status": "ok", "action": "repl_exit", "reason": "interrupt"}))
1014
+ sys.stdout.flush()
1015
+ break
1016
+
1017
+ if line is None:
1018
+ # Idle timeout reached — gracefully close
1019
+ print(json.dumps({
1020
+ "status": "ok",
1021
+ "action": "repl_exit",
1022
+ "reason": f"idle_timeout_{REPL_IDLE_TIMEOUT}s",
1023
+ "message": f"No input for {REPL_IDLE_TIMEOUT}s. Closing browser to prevent zombie process."
1024
+ }))
1025
+ sys.stdout.flush()
1026
+ audit_log("repl_idle_timeout", f"profile={session.profile}", f"timeout={REPL_IDLE_TIMEOUT}s")
1027
+ break
1028
+
1029
+ line = line.strip()
1030
+ if not line:
1031
+ continue
1032
+
1033
+ parts = line.split(maxsplit=1)
1034
+ cmd = parts[0].lower()
1035
+ arg = parts[1] if len(parts) > 1 else ""
1036
+
1037
+ # Every command output is structured JSON for agent parsing
1038
+ result = None
1039
+ try:
1040
+ if cmd in ('quit', 'exit', 'q'):
1041
+ print(json.dumps({"status": "ok", "action": "repl_exit", "reason": "user_quit"}))
1042
+ sys.stdout.flush()
1043
+ break
1044
+ elif cmd == 'help':
1045
+ result = {
1046
+ "status": "ok", "action": "help",
1047
+ "commands": [
1048
+ "open <url>", "screenshot [path]", "read-dom [selector]", "read-page",
1049
+ "click <selector>", "type <selector> <text>", "scroll [up|down]",
1050
+ "press <key>", "wait-for <selector>", "eval <js>",
1051
+ "gdoc-read", "gdoc-type <text>", "gdoc-find <text>",
1052
+ "stealth-test", "url", "title", "quit"
1053
+ ]
1054
+ }
1055
+ elif cmd == 'open':
1056
+ result = cmd_open(session, arg)
1057
+ elif cmd == 'screenshot':
1058
+ result = cmd_screenshot(session, arg or None)
1059
+ elif cmd in ('read-dom', 'readdom', 'dom'):
1060
+ result = cmd_read_dom(session, arg or None, sanitize)
1061
+ elif cmd in ('read-page', 'readpage', 'page'):
1062
+ result = cmd_read_page(session, sanitize)
1063
+ elif cmd == 'click':
1064
+ result = cmd_click(session, arg)
1065
+ elif cmd == 'type':
1066
+ tparts = arg.split(maxsplit=1)
1067
+ if len(tparts) == 2:
1068
+ result = cmd_type_text(session, tparts[0], tparts[1])
1069
+ else:
1070
+ result = {"status": "error", "action": "type", "message": "Usage: type <selector> <text>"}
1071
+ elif cmd == 'press':
1072
+ result = cmd_press(session, arg)
1073
+ elif cmd == 'scroll':
1074
+ result = cmd_scroll(session, arg or "down")
1075
+ elif cmd in ('wait-for', 'waitfor', 'wait'):
1076
+ result = cmd_wait_for(session, arg)
1077
+ elif cmd == 'eval':
1078
+ result = cmd_eval(session, arg, sanitize)
1079
+ elif cmd in ('gdoc-read', 'gdocread'):
1080
+ result = cmd_gdoc_read(session, sanitize)
1081
+ elif cmd in ('gdoc-type', 'gdoctype'):
1082
+ result = cmd_gdoc_type(session, arg)
1083
+ elif cmd in ('gdoc-find', 'gdocfind'):
1084
+ result = cmd_gdoc_find(session, arg)
1085
+ elif cmd in ('stealth-test', 'stealthtest', 'test'):
1086
+ result = cmd_stealth_test(session)
1087
+ elif cmd == 'url':
1088
+ result = {"status": "ok", "action": "url", "url": session.page.url}
1089
+ elif cmd == 'title':
1090
+ result = {"status": "ok", "action": "title", "title": session.page.title()}
1091
+ else:
1092
+ result = {"status": "error", "action": cmd, "message": f"Unknown command: {cmd}. Type 'help'."}
1093
+
1094
+ except Exception as e:
1095
+ # Error resilience: catch ALL exceptions, return JSON error, keep browser alive
1096
+ result = {
1097
+ "status": "error",
1098
+ "action": cmd,
1099
+ "error_type": type(e).__name__,
1100
+ "message": str(e)
1101
+ }
1102
+ audit_log("repl_error", cmd, f"{type(e).__name__}: {str(e)[:150]}")
1103
+
1104
+ if result:
1105
+ print(json.dumps(result, indent=2, default=str))
1106
+ sys.stdout.flush()
1107
+
1108
+
1109
+ # ---------------------------------------------------------------------------
1110
+ # Pipe/batch mode
1111
+ # ---------------------------------------------------------------------------
1112
+ def run_pipe(session, sanitize=False):
1113
+ """Read commands from stdin, one per line."""
1114
+ had_error = False
1115
+ for line in sys.stdin:
1116
+ line = line.strip()
1117
+ if not line or line.startswith('#'):
1118
+ continue
1119
+ parts = line.split(maxsplit=1)
1120
+ cmd = parts[0].lower()
1121
+ arg = parts[1] if len(parts) > 1 else ""
1122
+
1123
+ result = None
1124
+ try:
1125
+ if cmd == 'open':
1126
+ result = cmd_open(session, arg)
1127
+ elif cmd == 'screenshot':
1128
+ result = cmd_screenshot(session, arg or None)
1129
+ elif cmd == 'read-dom':
1130
+ result = cmd_read_dom(session, arg or None, sanitize)
1131
+ elif cmd == 'read-page':
1132
+ result = cmd_read_page(session, sanitize)
1133
+ elif cmd == 'click':
1134
+ result = cmd_click(session, arg)
1135
+ elif cmd == 'type':
1136
+ tparts = arg.split(maxsplit=1)
1137
+ if len(tparts) != 2:
1138
+ raise ValueError("Usage: type <selector> <text>")
1139
+ result = cmd_type_text(session, tparts[0], tparts[1])
1140
+ elif cmd == 'press':
1141
+ result = cmd_press(session, arg)
1142
+ elif cmd == 'scroll':
1143
+ result = cmd_scroll(session, arg or "down")
1144
+ elif cmd in ('wait-for', 'waitfor'):
1145
+ result = cmd_wait_for(session, arg)
1146
+ elif cmd == 'eval':
1147
+ result = cmd_eval(session, arg, sanitize)
1148
+ elif cmd == 'gdoc-read':
1149
+ result = cmd_gdoc_read(session, sanitize)
1150
+ elif cmd == 'gdoc-type':
1151
+ result = cmd_gdoc_type(session, arg)
1152
+ elif cmd == 'gdoc-find':
1153
+ result = cmd_gdoc_find(session, arg)
1154
+ elif cmd == 'wait':
1155
+ time.sleep(float(arg) if arg else 1)
1156
+ result = {"status": "ok", "action": "wait"}
1157
+ elif cmd in ('stealth-test', 'stealthtest', 'test'):
1158
+ result = cmd_stealth_test(session)
1159
+ elif cmd == 'url':
1160
+ result = {"status": "ok", "action": "url", "url": session.page.url}
1161
+ elif cmd == 'title':
1162
+ result = {"status": "ok", "action": "title", "title": session.page.title()}
1163
+ else:
1164
+ result = {"status": "error", "action": cmd, "message": f"Unknown command: {cmd}"}
1165
+ except Exception as e:
1166
+ result = {
1167
+ "status": "error",
1168
+ "action": cmd,
1169
+ "error_type": type(e).__name__,
1170
+ "message": str(e),
1171
+ }
1172
+
1173
+ if result and result.get("status") not in ("ok",):
1174
+ had_error = True
1175
+
1176
+ if result:
1177
+ print(json.dumps(result, default=str))
1178
+ sys.stdout.flush()
1179
+ return not had_error
1180
+
1181
+
1182
+ # ---------------------------------------------------------------------------
1183
+ # CLI
1184
+ # ---------------------------------------------------------------------------
1185
+ def build_parser():
1186
+ p = argparse.ArgumentParser(
1187
+ description='browse.py — HIPAA-Hardened Stealth Browser Automation CLI',
1188
+ formatter_class=argparse.RawDescriptionHelpFormatter
1189
+ )
1190
+ p.add_argument('--profile', default=DEFAULT_PROFILE, help='Browser profile name')
1191
+ p.add_argument('--headless', action='store_true', help='Headless mode')
1192
+ p.add_argument('--cleanup', action='store_true', help='Secure-wipe screenshots')
1193
+ p.add_argument('--sanitize', action='store_true', help='Mask PHI patterns')
1194
+ p.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT, help='Timeout (ms)')
1195
+ p.add_argument('--viewport', default='1440x900', help='Viewport WxH')
1196
+ p.add_argument('--stealth', choices=['full', 'light', 'none'], default='full',
1197
+ help='Stealth level: full (all layers), light (JS only), none')
1198
+ p.add_argument('--local-only', action='store_true',
1199
+ help='Reject non-loopback navigation and network requests')
1200
+ p.add_argument('--inject', action='append', default=[], metavar='PATH',
1201
+ help='Inject a UTF-8 .js/.mjs file before local page scripts (repeatable; requires --local-only)')
1202
+ p.add_argument('--skip-fv-check', action='store_true', help='Skip FileVault check')
1203
+
1204
+ sub = p.add_subparsers(dest='command')
1205
+
1206
+ sub.add_parser('repl', help='Interactive REPL (browser stays open)')
1207
+ sub.add_parser('pipe', help='Read commands from stdin (batch mode)')
1208
+
1209
+ s = sub.add_parser('open', help='Navigate to URL')
1210
+ s.add_argument('url')
1211
+
1212
+ s = sub.add_parser('screenshot', help='Take screenshot')
1213
+ s.add_argument('--output', '-o')
1214
+
1215
+ s = sub.add_parser('read-dom', help='Read DOM text')
1216
+ s.add_argument('--selector', '-s')
1217
+
1218
+ sub.add_parser('read-page', help='Full page text')
1219
+
1220
+ s = sub.add_parser('click', help='Click element')
1221
+ s.add_argument('selector')
1222
+
1223
+ s = sub.add_parser('type', help='Type text')
1224
+ s.add_argument('selector')
1225
+ s.add_argument('text')
1226
+
1227
+ s = sub.add_parser('press', help='Press a keyboard key or key combination')
1228
+ s.add_argument('key')
1229
+
1230
+ s = sub.add_parser('scroll', help='Scroll page')
1231
+ s.add_argument('--direction', '-d', choices=['up', 'down'], default='down')
1232
+ s.add_argument('--amount', '-a', type=int, default=3)
1233
+
1234
+ s = sub.add_parser('wait-for', help='Wait for element')
1235
+ s.add_argument('selector')
1236
+ s.add_argument('--wait-timeout', type=int)
1237
+
1238
+ s = sub.add_parser('eval', help='Evaluate JS')
1239
+ s.add_argument('js')
1240
+
1241
+ sub.add_parser('gdoc-read', help='Read Google Doc')
1242
+
1243
+ s = sub.add_parser('gdoc-type', help='Type in Google Doc')
1244
+ s.add_argument('text')
1245
+
1246
+ s = sub.add_parser('gdoc-find', help='Find in Google Doc')
1247
+ s.add_argument('text')
1248
+
1249
+ sub.add_parser('stealth-test', help='Run bot detection test')
1250
+
1251
+ return p
1252
+
1253
+
1254
+ def main():
1255
+ parser = build_parser()
1256
+ args = parser.parse_args()
1257
+
1258
+ if not args.command:
1259
+ parser.print_help()
1260
+ sys.exit(1)
1261
+
1262
+ if not args.skip_fv_check and not check_filevault():
1263
+ sys.exit(1)
1264
+
1265
+ try:
1266
+ vw, vh = args.viewport.split('x')
1267
+ viewport = (int(vw), int(vh))
1268
+ except Exception:
1269
+ viewport = DEFAULT_VIEWPORT
1270
+
1271
+ if args.inject and not args.local_only:
1272
+ parser.error('--inject requires --local-only')
1273
+ if args.local_only and args.command == 'open' and not is_local_test_url(args.url):
1274
+ parser.error('--local-only permits only loopback HTTP(S), data:, and about: URLs')
1275
+
1276
+ cleanup_files = []
1277
+ exit_code = 0
1278
+
1279
+ with StealthBrowserSession(
1280
+ profile=args.profile,
1281
+ headless=args.headless,
1282
+ timeout=args.timeout,
1283
+ viewport=viewport,
1284
+ stealth_level=args.stealth,
1285
+ local_only=args.local_only,
1286
+ init_script_paths=args.inject,
1287
+ ) as session:
1288
+
1289
+ if args.command == 'repl':
1290
+ run_repl(session, args.sanitize)
1291
+ elif args.command == 'pipe':
1292
+ if not run_pipe(session, args.sanitize):
1293
+ exit_code = 1
1294
+ elif args.command == 'stealth-test':
1295
+ print(json.dumps(cmd_stealth_test(session), indent=2, default=str))
1296
+ else:
1297
+ result = None
1298
+ if args.command == 'open':
1299
+ result = cmd_open(session, args.url)
1300
+ elif args.command == 'screenshot':
1301
+ result = cmd_screenshot(session, args.output, args.cleanup)
1302
+ if result and result.get('cleanup_path'):
1303
+ cleanup_files.append(result['cleanup_path'])
1304
+ elif args.command == 'read-dom':
1305
+ result = cmd_read_dom(session, getattr(args, 'selector', None), args.sanitize)
1306
+ if result and result.get('text'):
1307
+ print(result['text'])
1308
+ result = None
1309
+ elif args.command == 'read-page':
1310
+ result = cmd_read_page(session, args.sanitize)
1311
+ if result and result.get('text'):
1312
+ print(f"URL: {result['url']}\nTitle: {result['title']}\n\n{result['text']}")
1313
+ result = None
1314
+ elif args.command == 'click':
1315
+ result = cmd_click(session, args.selector)
1316
+ elif args.command == 'type':
1317
+ result = cmd_type_text(session, args.selector, args.text)
1318
+ elif args.command == 'press':
1319
+ result = cmd_press(session, args.key)
1320
+ elif args.command == 'scroll':
1321
+ result = cmd_scroll(session, args.direction, args.amount)
1322
+ elif args.command == 'wait-for':
1323
+ result = cmd_wait_for(session, args.selector, getattr(args, 'wait_timeout', None))
1324
+ elif args.command == 'eval':
1325
+ result = cmd_eval(session, args.js, args.sanitize)
1326
+ elif args.command == 'gdoc-read':
1327
+ result = cmd_gdoc_read(session, args.sanitize)
1328
+ if result and result.get('text'):
1329
+ print(result['text'])
1330
+ result = None
1331
+ elif args.command == 'gdoc-type':
1332
+ result = cmd_gdoc_type(session, args.text)
1333
+ elif args.command == 'gdoc-find':
1334
+ result = cmd_gdoc_find(session, args.text)
1335
+
1336
+ if result:
1337
+ print(json.dumps(result, indent=2, default=str))
1338
+
1339
+ for f in cleanup_files:
1340
+ secure_delete(f)
1341
+ return exit_code
1342
+
1343
+
1344
+ if __name__ == '__main__':
1345
+ sys.exit(main())