Skip to content

Commit 66c2698

Browse files
committed
feat(cloak): CloakBrowser patched chromium as default, opt-in install
Adopt CloakHQ/CloakBrowser as the default `chromium` for spawn — its 49-57 C++ source patches against canvas/WebGL/audio/font/GPU/WebRTC/screen/timing beat JS-shim stealth on surfaces detectors check at the API layer. Loader (src/umbra/cloak/): stdlib-only resolver against the upstream GitHub releases — manifest cached 24h (60/h anon GH limit), per-platform asset map, sha256-verified via SHA256SUMS, atomic extract into ~/.umbra/cloak/<tag>/, flock-serialized concurrent installs. No bundling (license forbids redistribution), no silent auto-download. Spawn wiring: `chromium="cloak"` resolves from cache; missing on a TTY prompts to install (`.declined` marker suppresses re-prompts); non-TTY (MCP/HTTP/CI) warns + falls back to stock. Env: UMBRA_NO_CLOAK kill-switch, UMBRA_CLOAK_BINARY pinned path. Stealth payload is forced to "minimal" when cloak is active to avoid double-fingerprinting cloak's native surface noise. UA-CH fix: cloak's internal stub clobbered `Network.setUserAgentOverride`, emptying `navigator.userAgentData.brands[*].version`. Payload now re-asserts userAgentData via defineProperty when cloak is active, matching the Python-side detected Chrome version. Avoids `Function.prototype.toString` Proxy (creepjs-detectable signature) — getHighEntropyValues.toString shows JS source instead of [native code], which is the smaller cost. CLI: `python -m umbra --setup` (and equivalent `umbra-cloak-setup`) for install / --force / --tag / --status / --uninstall. MCP exposes `cloak_status` + `cloak_install`. Tests: tests/test_cloak.py 17 cases (platform detect, sha256 parse, asset resolution, tar/zip extract, traversal guard, end-to-end lifecycle stubbed via file:// URLs, env override) + 2 e2e (real GH download, real spawn). Existing stealth regression suite passes 4/4 under cloak — sannysoft 29/31 whitelisted (cloak intentionally strips WebGL Vendor/Renderer strings), creepjs ≤5% headless/stealth unchanged, UA-CH now consistent, automation tells still zero.
1 parent 24fe64c commit 66c2698

13 files changed

Lines changed: 1532 additions & 13 deletions

‎README.md‎

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,12 @@ curl -k -H "x-api-key: $KEY" -X POST https://localhost:8765/api/tools/spawn \
153153
For now: one server = one trust domain. Run separate `umbra-server` processes on different ports if you need real isolation.
154154
- mTLS option (client-cert auth) instead of bearer keys.
155155
- per-key rate limiting + quotas.
156-
- **CloakBrowser integration** — adopt [CloakHQ/CloakBrowser](https://github.com/CloakHQ/CloakBrowser) patched chromium (49–57 C++ source patches: canvas/WebGL/audio/font/GPU/WebRTC/screen/timing) as the default chromium binary. JS-shim stealth (current) loses to native patches because detectors check shim artifacts. Plan:
157-
- `umbra setup` CLI → DL cloak binary from `cloakbrowser.dev`, SHA256 verify, cache `~/.umbra/cloak/<version>/`. License is free personal+commercial but **no redistribute** — must DL from upstream, never bundle.
158-
- `spawn({ chromium: 'cloak' | 'stock' | <path> })`, default `'cloak'`. First spawn w/ missing binary auto-DLs. Env overrides: `UMBRA_CLOAK_BINARY=<path>`, `UMBRA_NO_CLOAK=1` kill-switch. Unsupported platform → fail-soft to stock + warn (cloak supports linux x64/arm64, mac x64/arm64, win x64).
159-
- **humanize layer** port (ref `cloakbrowser/human/`, ~500–800 LOC TS under `src/humanize/`): bezier mouse curves w/ aim points, per-char typing w/ typos+self-correct, scroll accel/decel. Opt-in `humanize: true` on `aria_click`/`aria_type`/`click_at`/`scroll`/`drag`. Presets `default`/`careful` + custom config (`mistype_chance`, `typing_delay`, `idle_between_actions`). Works on stock chromium too.
160-
- **geoip-from-proxy** → lookup proxy exit IP, derive timezone+locale, apply via CDP `Emulation.setTimezoneOverride` + `setLocaleOverride`. Opt-in `geoip: true` on spawn. Cache lookups per proxy URL.
161-
- **deterministic fingerprint seed** — `spawn({ fingerprintSeed: 'abc' })` → seedable PRNG feeds `rotate_fingerprint`. Reproducible identity for session persistence + detection debugging.
162-
- **storage quota normalization** via CDP `Storage.overrideQuotaForOrigin` for fingerprintjs compat.
163-
- **WebRTC IP override** via CDP (partial — won't match cloak's native patch but better than current). `webrtcIp: 'auto' | <ip>`, `auto` reuses geoip exit IP.
156+
- **CloakBrowser integration** ✓ shipped — patched chromium is now the default. See [§ CloakBrowser](#-cloakbrowser-default-chromium). Deferred follow-ons:
157+
- **humanize layer** port (bezier mouse curves w/ aim points, per-char typing w/ typos+self-correct, scroll accel/decel). Opt-in on `aria_click`/`aria_type`/`click_at`/`scroll`/`drag`. Works on stock chromium too.
158+
- **geoip-from-proxy** → lookup proxy exit IP, derive timezone+locale, apply via CDP `Emulation.setTimezoneOverride` + `setLocaleOverride`. Opt-in `geoip: true` on spawn.
159+
- **deterministic fingerprint seed** — `spawn(fingerprint_seed='abc')` → seedable PRNG for `rotate_fingerprint` reproducibility.
160+
- **storage quota normalization** via CDP `Storage.overrideQuotaForOrigin`.
161+
- **WebRTC IP override** via CDP — covered by cloak's native patch when cloak is active; CDP fallback only useful on stock.
164162

165163
---
166164

@@ -460,6 +458,98 @@ Toggle off via `set_verbosity('full')` when you need raw byte-exact output. Loss
460458

461459
---
462460

461+
## 🥷 CloakBrowser (default chromium)
462+
463+
`spawn` defaults to `chromium="cloak"` — when the
464+
[CloakBrowser](https://github.com/CloakHQ/CloakBrowser) patched chromium
465+
build is installed under `~/.umbra/cloak/<tag>/`, every browser uses it.
466+
Cloak ships 49-57 C++ source patches against canvas, WebGL, audio, font,
467+
GPU, WebRTC, screen, and timing fingerprint surfaces. Native patches beat
468+
JS shims because detectors check the underlying API surface, not just
469+
property values — so umbra auto-downgrades its own `stealth_mode` to
470+
`minimal` (automation-tell cleanup only) when cloak is active, to avoid
471+
double-fingerprinting.
472+
473+
### setup (one-time)
474+
475+
Cloak is **not** bundled (license: free use, no redistribute) and is
476+
**not** silently auto-downloaded. Install once:
477+
478+
```bash
479+
python -m umbra --setup # download + verify + cache
480+
python -m umbra --setup --force # re-download
481+
python -m umbra --setup --tag <t> # pin a specific release
482+
python -m umbra --status # show install state (no network)
483+
python -m umbra --uninstall # wipe ~/.umbra/cloak/
484+
```
485+
486+
First `spawn()` with cloak missing **on an interactive TTY** prompts to
487+
install. Non-TTY (MCP/HTTP server, CI, scripts) silently falls back to
488+
stock chromium with a one-line warning — spawn never hangs on input.
489+
A `.declined` marker is written if the user says no, suppressing future
490+
prompts; delete `~/.umbra/cloak/.declined` to re-enable.
491+
492+
MCP tools: `cloak_status()`, `cloak_install(force=False, tag=None)`.
493+
494+
### opt-out
495+
496+
```bash
497+
# per-spawn:
498+
spawn(chromium="stock") # MCP / python
499+
# globally:
500+
export UMBRA_NO_CLOAK=1 # kill-switch — every spawn uses stock
501+
# or point at your own build:
502+
export UMBRA_CLOAK_BINARY=/path/to/chrome
503+
```
504+
505+
### platforms
506+
507+
| platform | cloak build | umbra behavior |
508+
|---|---|---|
509+
| linux x64 | ✓ | auto |
510+
| linux arm64 | ✓ | auto |
511+
| windows x64 | ✓ | auto |
512+
| darwin arm64| ✓ (separate tag) | auto |
513+
| darwin x64 | ✗ | fall back to stock + warn |
514+
| windows arm64 | ✗ | fall back to stock + warn |
515+
516+
GitHub anon API rate limit is 60/h — set `GITHUB_TOKEN` to lift it.
517+
Manifest cached 24h.
518+
519+
### measured impact (2026-05, linux-x64, headless)
520+
521+
Public aggregate detectors **do not visibly shift** with cloak — they mostly
522+
probe the surfaces JS shims already cover (`navigator.webdriver`, basic
523+
canvas hash, automation flags). The C++ patches harden deeper surfaces those
524+
aggregates don't score:
525+
526+
| signal | stock + JS shim | + cloak | note |
527+
|---|---|---|---|
528+
| creepjs headless % | ≤5 | ≤5 | unchanged — aggregate baseline |
529+
| creepjs stealth % | ≤5 | ≤5 | unchanged |
530+
| sannysoft pass | 30+/31 | 29/31 | WebGL Vendor/Renderer now report "no webgl context" — cloak strips the uniquely-identifying GPU strings on purpose (intentional surface cut, not a regression) |
531+
| automation tells | 0 | 0 | unchanged |
532+
| UA-CH brands.Chromium version | matches UA | **matches UA** | ✓ fixed — cloak's internal UA-CH stub clobbered our `Network.setUserAgentOverride`, so the minimal payload now re-asserts `navigator.userAgentData` via `defineProperty` from the Python-side detected Chrome version. Only injected under cloak. |
533+
534+
Where cloak actually helps (not aggregate-scored by the public detectors):
535+
canvas/audio per-pixel noise *patterns*, font enumeration consistency,
536+
exact GPU info strings, screen geometry edge cases, WebRTC IP leak at the
537+
C++ level, timing-API quantization. If your adversary fingerprints those
538+
specifically (FingerprintJS Pro, sift, akamai bot manager), cloak shifts
539+
the needle in ways `sannysoft`/`creepjs` summaries won't show.
540+
541+
### license note (read this)
542+
543+
CloakBrowser's binary license permits free personal **and** commercial *use*
544+
but forbids **redistribution**. umbra never bundles the binary — it always
545+
pulls from upstream releases on your machine. Don't repackage `~/.umbra/cloak`
546+
into your own product or container image you ship to third parties; the
547+
auto-download flow exists exactly so each user fetches their own copy. See
548+
[BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md)
549+
upstream for the exact terms.
550+
551+
---
552+
463553
## 🛡️ stealth coverage matrix
464554

465555
| detection vector | obscura | fantoma | sb-mcp | **umbra** |

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ all = [
5555

5656
[project.scripts]
5757
umbra-server = "umbra.server:main"
58+
umbra-cloak-setup = "umbra.cloak.__main__:main"
5859

5960
[project.urls]
6061
Homepage = "https://github.com/gabriel/umbra"

‎src/umbra/__main__.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Top-level umbra CLI.
2+
3+
python -m umbra --setup [--force] [--tag TAG]
4+
python -m umbra --status
5+
python -m umbra --uninstall
6+
python -m umbra server [server-args ...]
7+
8+
`--setup` is the friendly alias for `umbra-cloak-setup` — fetches the
9+
CloakBrowser patched chromium build, sha256-verifies it, caches it under
10+
~/.umbra/cloak/<tag>/, and exits. Spawn auto-installs on first use too,
11+
so this is for pre-warm or pinning a specific tag.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import sys
17+
18+
19+
_HELP = """\
20+
umbra — stealth Chrome automation
21+
22+
python -m umbra --setup [--force] [--tag TAG] install CloakBrowser chromium
23+
python -m umbra --status show cloak install state (no net)
24+
python -m umbra --uninstall wipe ~/.umbra/cloak/
25+
python -m umbra server [args] start the MCP / HTTP server
26+
python -m umbra --help show this help
27+
28+
Equivalent dedicated CLIs (installed by pip):
29+
umbra-cloak-setup [...]
30+
umbra-server [...]
31+
"""
32+
33+
34+
def main(argv: list[str] | None = None) -> int:
35+
argv = list(sys.argv[1:] if argv is None else argv)
36+
37+
if not argv or argv[0] in ("-h", "--help", "help"):
38+
print(_HELP)
39+
return 0
40+
41+
# Server passthrough.
42+
if argv[0] in ("server", "serve"):
43+
from umbra.server import main as server_main
44+
# umbra.server.main reads sys.argv directly — rewrite it so its
45+
# argparse sees only its own args.
46+
sys.argv = ["umbra-server", *argv[1:]]
47+
server_main()
48+
return 0
49+
50+
# Cloak passthrough — strip a leading "--setup" since the cloak CLI
51+
# treats install as the default action.
52+
if argv[0] == "--setup":
53+
argv = argv[1:]
54+
from umbra.cloak.__main__ import main as cloak_main
55+
return cloak_main(argv)
56+
57+
58+
if __name__ == "__main__":
59+
sys.exit(main())

‎src/umbra/browser.py‎

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,19 @@ class StealthOptions:
251251
extra_args: list[str] = field(default_factory=list)
252252
window_size: tuple[int, int] = (1920, 1080)
253253

254+
# Chromium binary selection:
255+
# "cloak" (default) → CloakBrowser patched build (canvas/audio/webgl/
256+
# font/gpu/webrtc/screen/timing C++ patches). Auto-downloaded
257+
# on first spawn into ~/.umbra/cloak/<tag>/. Beats JS shims.
258+
# "stock" → use the system chromium (chrome_path or auto-detect).
259+
# "<path>" → absolute path to a chromium binary (overrides chrome_path).
260+
# Env overrides:
261+
# UMBRA_NO_CLOAK=1 → force stock even if chromium="cloak"
262+
# UMBRA_CLOAK_BINARY=<path> → use this binary as cloak (skip DL)
263+
# Unsupported platforms (mac-x64, win-arm64, exotic arches) fall back to
264+
# stock with a warning instead of raising.
265+
chromium: str = "cloak"
266+
254267
# Performance / footprint
255268
# low_memory=True: cut ~80-150MB resident RAM via plausible feature
256269
# disables. Stealth-neutral. Use for parallel session farms or
@@ -277,13 +290,127 @@ def __init__(self, options: StealthOptions | None = None):
277290
self._pool_browser_id: str | None = None
278291
self._pool_entry: Any = None # ProxyEntry held for this session
279292
self._proxy_creds: tuple[str, str] | None = None # (user, pass) for CDP auth
293+
# Set during start() — True when chrome_path was resolved from cloak
294+
# cache (or env). Read by _configure_tab → inject.install to skip JS
295+
# shims that overlap with cloak's native patches.
296+
self._cloak_active: bool = False
280297

281298
@property
282299
def browser(self) -> uc.Browser:
283300
if self._browser is None:
284301
raise RuntimeError("StealthBrowser not started — call await start() first")
285302
return self._browser
286303

304+
@staticmethod
305+
def _cloak_prompt_install() -> bool:
306+
"""Ask the user (TTY only) whether to install cloak now.
307+
308+
Returns True iff the user agreed and the install succeeded. Persists
309+
a `.declined` marker in the cache root when the user says no, so we
310+
don't prompt again on subsequent spawns. Non-interactive callers
311+
(no TTY, MCP/HTTP server, CI) silently return False.
312+
"""
313+
import sys as _sys
314+
from umbra.cloak import CloakUnavailable, install_latest
315+
from umbra.cloak.loader import cache_root
316+
317+
root = cache_root()
318+
declined = root / ".declined"
319+
if declined.exists():
320+
return False
321+
if not (_sys.stdin.isatty() and _sys.stderr.isatty()):
322+
return False
323+
try:
324+
print(
325+
"\nCloakBrowser is not installed. It's a patched chromium build "
326+
"that beats JS-shim stealth via native C++ patches.",
327+
file=_sys.stderr,
328+
)
329+
print(
330+
"Install now? (~80 MB download, cached to ~/.umbra/cloak/) [Y/n] ",
331+
end="", file=_sys.stderr, flush=True,
332+
)
333+
ans = _sys.stdin.readline().strip().lower()
334+
except (EOFError, KeyboardInterrupt):
335+
return False
336+
if ans not in ("", "y", "yes"):
337+
try:
338+
root.mkdir(parents=True, exist_ok=True)
339+
declined.write_text(
340+
"user declined cloak install — remove this file to re-prompt\n"
341+
)
342+
print(
343+
"skipping cloak — to install later: `python -m umbra --setup`",
344+
file=_sys.stderr,
345+
)
346+
except OSError:
347+
pass
348+
return False
349+
print("downloading CloakBrowser ...", file=_sys.stderr, flush=True)
350+
try:
351+
install_latest()
352+
except CloakUnavailable as e:
353+
print(f"cloak install failed: {e}", file=_sys.stderr)
354+
return False
355+
return True
356+
357+
def _resolve_chromium_binary(self) -> Any:
358+
"""Pick the chromium binary per `chromium=` option + env overrides.
359+
360+
Returns a pathlib.Path (or None when caller should leave the existing
361+
chrome_path / nodriver auto-detect in place).
362+
363+
Resolution order, highest priority first:
364+
1. env UMBRA_NO_CLOAK=1 → stock (None)
365+
2. opts.chromium = "<absolute path>" → that path
366+
3. opts.chromium = "stock" → stock (None)
367+
4. env UMBRA_CLOAK_BINARY=<path> → that path (no DL)
368+
5. opts.chromium = "cloak" (default) → resolve+install cloak
369+
On any cloak failure (unsupported platform / DL error) we log a
370+
warning and return None so the caller falls back to stock.
371+
"""
372+
if os.environ.get("UMBRA_NO_CLOAK"):
373+
log.info("UMBRA_NO_CLOAK set — using stock chromium")
374+
return None
375+
376+
choice = (self.options.chromium or "cloak").strip()
377+
# Treat an absolute / explicit path as a manual override.
378+
if choice not in ("cloak", "stock") and choice:
379+
from pathlib import Path as _P
380+
p = _P(choice).expanduser()
381+
if not p.is_file():
382+
log.warning("chromium=%r not found — falling back to stock", choice)
383+
return None
384+
return p
385+
386+
if choice == "stock":
387+
return None
388+
389+
# cloak path.
390+
try:
391+
from umbra.cloak import CloakUnavailable, resolve_cloak_binary
392+
except Exception as e: # noqa: BLE001
393+
log.warning("cloak loader import failed (%s) — using stock", e)
394+
return None
395+
# No silent auto-download. Try the cache first; if empty AND we're on
396+
# an interactive TTY, prompt the user once. Non-TTY callers (MCP/HTTP
397+
# server, CI) get a warn-and-fall-back so spawn doesn't hang.
398+
try:
399+
return resolve_cloak_binary(auto_download=False)
400+
except CloakUnavailable:
401+
pass
402+
if self._cloak_prompt_install():
403+
try:
404+
return resolve_cloak_binary(auto_download=False)
405+
except CloakUnavailable as e:
406+
log.warning("cloak install reported ok but resolve failed: %s", e)
407+
return None
408+
log.warning(
409+
"CloakBrowser not installed — using stock chromium. "
410+
"Run `python -m umbra --setup` to fetch the patched build.",
411+
)
412+
return None
413+
287414
async def start(self) -> uc.Browser:
288415
opts = self.options
289416
flags: list[str] = list(_BASE_FLAGS) + list(opts.extra_args)
@@ -298,6 +425,22 @@ async def start(self) -> uc.Browser:
298425
if opts.low_memory:
299426
flags.extend(_LOW_MEMORY_FLAGS)
300427

428+
# Resolve chromium binary selection. cloak wins by default — its C++
429+
# patches against canvas/audio/webgl/font/gpu/webrtc surfaces beat our
430+
# JS shims because detectors check the underlying API surface, not
431+
# property values. JS shims are layered on top only for tells that
432+
# live purely in JS-land (navigator.webdriver etc.); the per-surface
433+
# noise is short-circuited when cloak is active so we don't
434+
# double-fingerprint the page.
435+
cloak_path = self._resolve_chromium_binary()
436+
if cloak_path is not None:
437+
# Override chrome_path; downstream detect_chrome_version + nodriver
438+
# use this path.
439+
opts.chrome_path = str(cloak_path)
440+
self._cloak_active = True
441+
else:
442+
self._cloak_active = False
443+
301444
# Detect actual Chrome version → build matching UA + UA-CH metadata.
302445
# We MUST pin all three (UA string, UA-CH brands, UA-CH fullVersionList)
303446
# to the same version, or detectors flag the inconsistency.
@@ -428,11 +571,32 @@ async def new_tab(self, url: str = "about:blank") -> Any:
428571
async def _configure_tab(self, tab: Any) -> None:
429572
"""Install stealth payload + apply CDP overrides on a tab."""
430573
opts = self.options
574+
# If cloak is active, force minimal payload regardless of user setting.
575+
# Cloak's C++ patches already noise canvas/audio/webgl/font/etc at the
576+
# API surface — layering JS-side noise on top would (a) double-jitter
577+
# the fingerprint (cloak applies once, JS applies again), and (b) the
578+
# JS hooks themselves are detectable as stealth-lib signatures.
579+
# Minimal payload only strips automation tells — orthogonal to cloak.
580+
effective_mode = opts.stealth_mode
581+
if self._cloak_active and effective_mode != "minimal":
582+
log.info(
583+
"cloak active — overriding stealth_mode=%r → 'minimal' "
584+
"(cloak handles per-surface noise natively)",
585+
effective_mode,
586+
)
587+
effective_mode = "minimal"
431588
await install_stealth(
432589
tab,
433590
timezone=opts.timezone,
434591
chrome_version=self._chrome_version,
435-
mode=opts.stealth_mode, # type: ignore[arg-type]
592+
mode=effective_mode, # type: ignore[arg-type]
593+
# Pass UA-CH only when cloak is active — stock chromium honors
594+
# Network.setUserAgentOverride below correctly, so a JS-side
595+
# redefinition there would be unnecessary noise (and an extra
596+
# detectable hook). With cloak, its internal UA-CH stub clobbers
597+
# CDP's override → we re-assert from JS via defineProperty so
598+
# navigator.userAgentData matches the UA string we pinned.
599+
ua_metadata=self._ua_meta if self._cloak_active else None,
436600
)
437601

438602
cdp = uc.cdp

0 commit comments

Comments
 (0)