From 2f683772810df72dbef3f0e389b3a5cadf68076f Mon Sep 17 00:00:00 2001
From: Mateusz Dalke <150788324+dalkedev@users.noreply.github.com>
Date: Sat, 7 Nov 2026 07:52:51 +0000
Subject: [PATCH 01/18] docs: specify Scope-owned Android interception boundary
---
.../plans/2026-11-07-owned-android-engine.md | 52 +++++++++++++++++++
.../2026-11-07-owned-android-engine-design.md | 37 +++++++++++++
2 files changed, 89 insertions(+)
create mode 100644 docs/superpowers/plans/2026-11-07-owned-android-engine.md
create mode 100644 docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
diff --git a/docs/superpowers/plans/2026-11-07-owned-android-engine.md b/docs/superpowers/plans/2026-11-07-owned-android-engine.md
new file mode 100644
index 0000000..24558ef
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-07-owned-android-engine.md
@@ -0,0 +1,52 @@
+# Scope-owned Android engine Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace HTTP Toolkit runtime hooks with an original CA-scoped Android interception engine in one independently reviewed PR.
+**Architecture:** Original JavaScript hook/policy code runs in the selected app through the existing Python/Frida worker. Rust retains proxy/recovery management and materializes only the new assets. Imported system trust injection and unsupported root profiles are removed explicitly.
+**Tech Stack:** JavaScript, Python, Rust, existing Frida Java bridge; pinned existing dependencies.
+**Spec:** docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
+
+## Global Constraints
+
+- Preserve existing Rust proxy/TLS/archive behavior and source history; no license change.
+- New runtime implementations must be original, not copied or mechanically translated upstream hooks.
+- Supported profiles are `proxy`, `certificate`, `unpinning`; an empty selection means all three. `unpinning` requires `certificate`.
+- Unsupported `root`, `system-ca` or unknown profiles fail before any device probes or state mutation.
+- No trust-all manager, hostname-verifier bypass, certificate-expiry bypass or global system trust-store mutation is allowed.
+- Only validated Scope-CA chains may bypass supported OkHttp pin checks; all other chains keep original checks/exceptions.
+- Readiness requires installed requested capabilities. Stop terminates only the exact worker-spawned process; failures remain visible.
+- Keep legacy CA removal recovery; document proxy/root/native coverage reductions.
+- Public artifacts contain no private context, paths, captures, credentials or machine identifiers.
+- Never claim mock tests are Android results. Real unavailable gates are NOT RUN. No merge/release/tags/admin/engine changes.
+
+## Review Focus
+
+- Unrelated/expired/malformed chains must not gain trust or bypass pins.
+- Partial hook installation and missing overloads must roll back, never report ready.
+- Explicit profiles including stale root/system-ca values must fail before device writes.
+- Worker cancellation/startup error must not leave an owned instrumented process active or kill unrelated PIDs.
+- Old recovery records must still clean only Scope-owned CA/mappings after injection is removed.
+
+### Task 1: Original hook engine and worker contract
+
+**Files:** Create `resources/frida/scope-engine.js`, `resources/frida/tests/scope-engine.test.cjs`, `resources/device/tests/test_frida_worker.py`; modify `resources/device/frida_worker.py`.
+**Interfaces:** Worker `build_script(root, ca, port, profiles) -> str` remains available. `normalize_profiles(profiles) -> list[str]` is shared by the CLI/builder. New JS exports a testable engine factory/policy boundary and runs through documented Frida `Java` APIs. JSON runtime config uses public CA/loopback proxy/normalized profiles. Readiness payload identifies engine `scope-java-v1`, selected profiles and installed capabilities; Python validates it. Existing Rust JSONL `ready/error/stopped/detached` kinds remain compatible.
+
+- [ ] Write executable JS and Python failing tests for CA-scoped pin decisions, original rejection propagation, original trust delegation, explicit/default/unsupported profiles, missing classes and transactional rollback, hostile config serialization, readiness validation and owned-PID cleanup.
+- [ ] Observe RED with expected missing implementation or wrong existing behavior; record exact commands/output in report.
+- [ ] Implement original engine (small focused helpers in one runtime file), worker JSON config construction, capability-aware ready gate and finite owned-process cleanup; preserve CLI listing/version and bounded diagnostics. Use the actual Frida API, not a production test stub. Any unimplemented requested capability is an error.
+- [ ] Run focused tests plus complete Python device suite; self-review. Do not claim fixture-based adapter tests are device verification.
+- [ ] Commit only Task1 files and report exact RED/GREEN and remaining limitations.
+
+### Task 2: Application integration, migration and release verification
+
+**Files:** Modify `crates/scope-device/src/lib.rs`, `src/App.tsx`, `README.md`, `docs/ANDROID.md`, `THIRD_PARTY_NOTICES.md`, `.github/workflows/checks.yml`, affected existing tests; remove six imported HTK runtime JS/config files and `resources/device/install-system-ca.template.sh`; retain Frida bridge/licenses. Add `docs/ANDROID_ENGINE.md` with explicit coverage/evidence.
+**Interfaces:** Consume Task1 `scope-engine.js`, `normalize_profiles` and JSONL engine/capability payload. Rust rejects unsupported profiles before ADB/root probes; ASSETS materializes only `frida/scope-engine.js`, existing Frida bridge and worker. Remove automatic system CA installation, preserve cleanup of old recovery ca_hash. UI removes unsupported root option and explains Stop ends the instrumented process.
+
+- [ ] Add regression tests for early unsupported profile rejection, asset construction and legacy owned-CA recovery behavior where affected; observe RED.
+- [ ] Wire original engine and remove imported runtime source/injection path. Preserve unrelated Rust proxy and recovery semantics.
+- [ ] Update docs/notices accurately including retained bridge distribution provenance, current original engine, explicit reduced coverage and device NOT RUN gates; no licensing cleanup beyond truthful component inventory.
+- [ ] Add the executable JS engine test command to the existing three-platform checks using existing pinned actions. Keep existing checks/security unchanged.
+- [ ] Run focused Rust device tests, all JS/Python suites, UI tests/build when available, formatting and source/privacy/provenance checks; record unavailable desktop/device gates explicitly.
+- [ ] Commit, report evidence. Root obtains independent spec/quality review, fixes findings with regression tests, creates exactly one PR only after local review, reconciles hosted checks on exact head. Keep draft if real device gates are unavailable.
diff --git a/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md b/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
new file mode 100644
index 0000000..8504754
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
@@ -0,0 +1,37 @@
+# Scope-owned Android interception engine
+
+## Intent and scope
+
+Replace HTTP Toolkit-origin interception/unpinning scripts with original Scope code using documented Android, Java and Frida APIs. Retain Frida and its independently attributed Java bridge as third-party instrumentation infrastructure. This is a source migration, not a new TLS implementation or a license change. Retain public source history and accurate third-party notices.
+
+The existing Scope Rust HTTP(S) proxy, CA, archive and upstream certificate verification stay unchanged. New runtime code must not be copied or mechanically translated from the removed upstream hooks. No claim of legally certified clean-room development is made.
+
+## Approach
+
+A small per-process Java engine is the default and only built-in hook backend. Keeping the imported hook backend indefinitely would leave the ownership problem unresolved. Reimplementing Frida or every native networking stack would expand this work beyond a reviewable change. Scope-owned Java hooks on Frida provide a bounded, testable boundary.
+
+Supported profiles are `proxy`, `certificate`, `unpinning`; an empty selection means all three. `unpinning` requires `certificate`. Unsupported `root`, `system-ca` or unknown profiles fail before any device probes or state mutation. No native TLS, QUIC, Flutter, Cronet, custom obfuscated pinners, child process or root-hiding coverage is claimed.
+
+`proxy` directs supported Java HTTP clients to the ADB-reversed loopback Scope listener. `certificate` adds trust only for chains validated against the supplied Scope CA while preserving original validation for other chains. No trust-all manager, hostname-verifier bypass, certificate-expiry bypass or global system trust-store mutation is allowed. `unpinning` may skip supported OkHttp pin checks only after the peer chain validates against the configured Scope CA; non-Scope chains must execute original pin checks and preserve their result/exception.
+
+## Runtime and lifecycle contract
+
+The Python worker validates finite profile, PEM and port inputs before device work. Source construction uses JSON serialization for runtime data, not source-text replacement. The engine reports per-capability installation results. Readiness requires successful installation of every requested profile, not merely entry into Java.perform. Unsupported classes/overloads or hook errors produce actionable bounded diagnostics and no success claim. Installation failure rolls back installed method implementations.
+
+Process-created proxy/trust objects can outlive method unhooking. Therefore Stop terminates the exact process spawned by this worker, then unloads/detaches; it does not promise that unloading alone restores the running app. It must not kill an unrelated process. Cleanup errors remain visible, and no clean-stop receipt is emitted after failed cleanup. Existing Rust recovery of owned ADB/server state and legacy CA removal remains available after restart.
+
+## Compatibility changes
+
+Remove the imported system-CA injection template and automatic rooted proxy-mode trust-store injection. System proxy mode remains device-wide; HTTPS requires manually configured application trust. Do not remove legacy CA cleanup for recovery records created by earlier versions. Remove root-hiding selection from the UI; stale API/CLI selections are rejected before mutations. Explain these reductions prominently instead of claiming full parity with the imported scripts.
+
+## Validation and delivery
+
+Use executable JavaScript policy/adapter tests, worker builder/lifecycle tests and existing Rust/UI/device/release suites. Include controls proving unrelated or invalid chains still fail, original pin exceptions survive, unsupported capabilities never become ready, partial startup rolls back, and Stop ends only the owned PID. Simulated Frida/Java adapter tests are explicitly not Android execution evidence.
+
+Real Android/Frida/OkHttp TLS, app-stop restoration and API/ABI compatibility are separate acceptance gates. Record exact PASS / FAIL / NOT RUN without inventing coverage. An independently reviewed PR may remain draft if these device gates cannot be executed. No merge, release, tags, engine/service changes or live external account actions.
+
+## Primary API references
+
+- https://frida.re/docs/javascript-api/#java
+- https://developer.android.com/privacy-and-security/security-config
+- https://square.github.io/okhttp/features/https/
From 8754c03368b7b105cfba5c83cad83328e242a8f9 Mon Sep 17 00:00:00 2001
From: Mateusz Dalke <150788324+dalkedev@users.noreply.github.com>
Date: Sat, 7 Nov 2026 08:04:00 +0000
Subject: [PATCH 02/18] feat: add Scope-owned Java interception engine and
worker lifecycle
---
resources/device/frida_worker.py | 302 ++++++++++++++------
resources/device/tests/test_frida_worker.py | 179 ++++++++++++
resources/frida/scope-engine.js | 299 +++++++++++++++++++
resources/frida/tests/scope-engine.test.cjs | 254 ++++++++++++++++
4 files changed, 950 insertions(+), 84 deletions(-)
create mode 100644 resources/device/tests/test_frida_worker.py
create mode 100644 resources/frida/scope-engine.js
create mode 100644 resources/frida/tests/scope-engine.test.cjs
diff --git a/resources/device/frida_worker.py b/resources/device/frida_worker.py
index 9e3d5fe..2dbf8c5 100644
--- a/resources/device/frida_worker.py
+++ b/resources/device/frida_worker.py
@@ -1,53 +1,234 @@
#!/usr/bin/env python3
-"""Scope's isolated Frida worker. Stdout is JSON Lines; stdin EOF requests cleanup.
+"""Scope's isolated Frida worker. Stdout is JSON Lines; stdin EOF requests Stop.
-The worker controls exactly one selected application. Hook source stays JavaScript.
-Python/Frida is deliberately outside the desktop process so an ART crash cannot
-take the session archive or UI with it. No HTTP Toolkit installation is used.
+The worker owns one spawned application process. Stop ends that process because
+unloading methods cannot revoke already-created proxy or TLS objects.
"""
import argparse
+from collections import deque
+from contextlib import nullcontext
import json
from pathlib import Path
import re
import signal
import sys
import threading
+import time
-PROFILES = {
- "proxy": ["android/android-proxy-override.js"],
- "certificate": ["android/android-system-certificate-injection.js"],
- "unpinning": ["android/android-certificate-unpinning.js", "android/android-certificate-unpinning-fallback.js"],
- "root": ["android/android-disable-root-detection.js"],
-}
+PROFILES = ("proxy", "certificate", "unpinning")
+CAPABILITIES = {"proxy": "java-url", "certificate": "sslcontext-scope-ca", "unpinning": "okhttp3-certificate-pinner"}
def emit(kind, **fields):
- # Keep Frida diagnostics local and avoid logging tokens inside query strings.
if "message" in fields:
fields["message"] = re.sub(r"(https?://[^\s?]+)\?[^\s]+", r"\1?[query omitted]", str(fields["message"]))[:4000]
print(json.dumps({"kind": kind, **fields}), flush=True)
+def normalize_profiles(profiles):
+ if not isinstance(profiles, (list, tuple)) or any(not isinstance(p, str) or p not in PROFILES for p in profiles):
+ raise ValueError("Unsupported hook profile; supported: proxy, certificate, unpinning")
+ result = [p for p in PROFILES if p in profiles] if profiles else list(PROFILES)
+ if "unpinning" in result and "certificate" not in result:
+ raise ValueError("unpinning profile requires certificate")
+ return result
+
+
def build_script(root, ca, port, profiles):
- cert = ca.read_text().strip()
- if not re.fullmatch(r"-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----", cert):
+ profiles = normalize_profiles(profiles)
+ if type(port) is not int or not 1 <= port <= 65535:
+ raise ValueError("A valid proxy port is required")
+ if ca.stat().st_size > 65536:
+ raise ValueError("Scope CA PEM is too large")
+ cert = ca.read_text(encoding="utf-8").strip()
+ if len(cert) > 65536 or not re.fullmatch(r"-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----", cert, flags=re.ASCII):
raise ValueError("Scope CA must be a public PEM certificate")
- unknown = set(profiles) - set(PROFILES)
- if unknown:
- raise ValueError("Unknown hook profile: " + ", ".join(sorted(unknown)))
- config = (root / "config-template.js").read_text()
- config, count = re.subn(r"const CERT_PEM = `.*?`;", lambda _: "const CERT_PEM = `" + cert + "`;", config, count=1, flags=re.S)
- if count != 1:
- raise ValueError("Unexpected Frida configuration template")
- config, count = re.subn(r"const PROXY_PORT = \d+;", "const PROXY_PORT = %d;" % port, config, count=1)
- if count != 1:
- raise ValueError("Missing proxy port configuration")
- scripts = [(root / "frida-java-bridge.js").read_text(), config]
- for profile in profiles:
- for name in PROFILES[profile]:
- scripts.append((root / name).read_text())
- scripts.append("Java.perform(() => send({kind: 'scope-hooks-ready'}));\n")
- return "\n".join(scripts)
+ config = {"caPem": cert, "proxyHost": "127.0.0.1", "proxyPort": port, "profiles": profiles}
+ return "\n".join([
+ (root / "frida-java-bridge.js").read_text(encoding="utf-8"),
+ (root / "scope-engine.js").read_text(encoding="utf-8"),
+ "globalThis.ScopeJavaEngine.start(" + json.dumps(config, ensure_ascii=True) + ");\n",
+ ])
+
+
+def validate_ready(payload, profiles):
+ selected = normalize_profiles(profiles)
+ if not isinstance(payload, dict) or payload.get("kind") != "scope-hooks-ready" or payload.get("engine") != "scope-java-v1" or payload.get("profiles") != selected:
+ raise ValueError("Invalid Scope engine readiness identity or profiles")
+ capabilities = payload.get("capabilities")
+ if not isinstance(capabilities, dict) or set(capabilities) != set(selected):
+ raise ValueError("Missing requested profile capabilities")
+ for profile in selected:
+ installed = capabilities[profile]
+ if not isinstance(installed, list) or any(not isinstance(c, str) for c in installed) or CAPABILITIES[profile] not in installed:
+ raise ValueError("Missing installed capability for profile " + profile)
+ return payload
+
+
+def bounded_call(label, operation, timeout):
+ done = threading.Event()
+ outcome = []
+
+ def run():
+ try:
+ outcome.append((True, operation()))
+ except BaseException as error:
+ outcome.append((False, error))
+ finally:
+ done.set()
+
+ threading.Thread(target=run, daemon=True).start()
+ if not done.wait(timeout):
+ raise RuntimeError(label + " timed out; completion is unknown")
+ success, value = outcome[0]
+ if not success:
+ raise RuntimeError(label + ": " + str(value)) from value
+ return value
+
+
+def cleanup_owned(device, pid, script, session, timeout=3, process_not_found=()):
+ errors = []
+
+ def kill():
+ try:
+ device.kill(pid)
+ except process_not_found:
+ pass # Frida confirms this process already ended.
+
+ def unload():
+ if script.is_destroyed is not True:
+ try:
+ script.unload()
+ except Exception:
+ if script.is_destroyed is not True:
+ raise
+
+ def detach():
+ if session.is_detached is not True:
+ try:
+ session.detach()
+ except Exception:
+ if session.is_detached is not True:
+ raise
+
+ operations = []
+ if pid is not None:
+ operations.append(("kill owned application", kill))
+ if script is not None:
+ operations.append(("unload hooks", unload))
+ if session is not None:
+ operations.append(("detach session", detach))
+ for label, operation in operations:
+ try:
+ bounded_call(label, operation, timeout)
+ except Exception as error:
+ errors.append(str(error))
+ if errors:
+ raise RuntimeError("Cleanup failed: " + "; ".join(errors))
+
+
+def run_session(device, package, source, profiles, version, stopped, ready_timeout=30, cleanup_timeout=3, process_not_found=(), cancellable_factory=None):
+ profiles = normalize_profiles(profiles)
+ ready = threading.Event()
+ exited = threading.Event()
+ failure = deque(maxlen=2)
+ receipt = []
+ replaced = threading.Event()
+ session = script = pid = None
+
+ def receive(message, _data):
+ payload = message.get("payload")
+ if message.get("type") == "send" and isinstance(payload, dict):
+ if payload.get("kind") == "scope-hooks-ready":
+ if failure or stopped.is_set():
+ return
+ try:
+ receipt[:] = [validate_ready(payload, profiles)]
+ ready.set()
+ except ValueError as error:
+ failure.append(str(error))
+ stopped.set()
+ return
+ if payload.get("kind") == "scope-hooks-error":
+ failure.append(str(payload.get("message", "Hook installation failed"))[:2000])
+ stopped.set()
+ return
+ if message.get("type") == "error":
+ failure.append(str(message.get("stack") or message.get("description") or "Hook error")[:2000])
+ stopped.set()
+ emit("log", message=message.get("stack") or payload or message.get("description") or message)
+
+ def detached(reason, crash=None):
+ if reason == "process-terminated":
+ exited.set()
+ elif reason == "process-replaced":
+ replaced.set()
+ failure.append("Owned process image was replaced; termination cannot be confirmed safely")
+ emit("detached", message=reason, crash=bool(crash))
+ stopped.set()
+
+ primary_error = None
+ startup_complete = threading.Event()
+ deadline = time.monotonic() + ready_timeout
+ cancellable = cancellable_factory() if cancellable_factory else None
+
+ def cancel_startup():
+ while not startup_complete.wait(0.025):
+ if stopped.is_set():
+ cancellable.cancel()
+ return
+ if time.monotonic() >= deadline:
+ failure.append("Startup deadline expired; pending operation completion may be unknown")
+ stopped.set()
+ cancellable.cancel()
+ return
+
+ if cancellable is not None:
+ threading.Thread(target=cancel_startup, daemon=True).start()
+ attempted_spawn = False
+ try:
+ if stopped.is_set():
+ return
+ with cancellable if cancellable is not None else nullcontext():
+ attempted_spawn = True
+ pid = device.spawn([package])
+ emit("spawned", engine="scope-java-v1", pid=pid)
+ session = device.attach(pid)
+ session.on("detached", detached)
+ script = session.create_script(source)
+ script.on("message", receive)
+ script.load()
+ if not stopped.is_set():
+ device.resume(pid)
+ startup_complete.set()
+ while not ready.is_set() and not stopped.is_set() and time.monotonic() < deadline:
+ ready.wait(min(0.05, max(0, deadline - time.monotonic())))
+ if failure:
+ raise RuntimeError("Hook startup failed: " + "; ".join(failure))
+ if stopped.is_set():
+ if failure:
+ raise RuntimeError("Hook startup failed: " + "; ".join(failure))
+ return
+ if not ready.is_set():
+ raise RuntimeError("Java hooks did not become ready within the startup deadline")
+ emit("ready", pid=pid, version=version, engine=receipt[0]["engine"], profiles=profiles, capabilities=receipt[0]["capabilities"])
+ stopped.wait()
+ if failure:
+ raise RuntimeError("Hook runtime failed: " + "; ".join(failure))
+ except BaseException as error:
+ primary_error = RuntimeError("Spawn did not return an owned PID; completion is unknown: " + str(error)) if attempted_spawn and pid is None else error
+ finally:
+ startup_complete.set()
+ try:
+ cleanup_owned(device, None if exited.is_set() or replaced.is_set() else pid, script, session, cleanup_timeout, process_not_found)
+ except Exception as error:
+ if primary_error:
+ raise RuntimeError(str(primary_error) + "; " + str(error)) from primary_error
+ raise
+ if not replaced.is_set() and pid is not None:
+ emit("stopped", engine="scope-java-v1", pid=pid, processTerminated=True, message="Owned application process ended; hooks unloaded and session detached")
+ if primary_error:
+ raise primary_error
def main():
@@ -62,6 +243,12 @@ def main():
parser.add_argument("--profile", action="append", default=[])
parser.add_argument("--list-apps", action="store_true")
args = parser.parse_args()
+ profiles = normalize_profiles(args.profile) # Reject stale profiles before any device operation.
+ source = None
+ if not args.version and not args.list_apps:
+ if not args.package or not args.ca or not args.scripts:
+ raise ValueError("Package, public CA and scripts are required")
+ source = build_script(args.scripts, args.ca, args.port, profiles)
try:
import frida
except ImportError as error:
@@ -76,29 +263,7 @@ def main():
if args.list_apps:
emit("apps", apps=[{"package": app.identifier, "name": app.name, "running": bool(app.pid)} for app in device.enumerate_applications()])
return
- if not args.package or not args.ca or not args.scripts or not 1 <= args.port <= 65535:
- raise ValueError("Package, public CA, scripts and a valid port are required")
- source = build_script(args.scripts, args.ca, args.port, args.profile or ["proxy", "certificate", "unpinning"])
stopped = threading.Event()
- ready = threading.Event()
- failure = []
- session = None
- script = None
- pid = None
- resumed = False
-
- def receive(message, _data):
- payload = message.get("payload")
- if message.get("type") == "send" and isinstance(payload, dict) and payload.get("kind") == "scope-hooks-ready":
- ready.set()
- return
- if message.get("type") == "error":
- failure.append(message.get("stack") or message.get("description") or "Hook error")
- emit("log", message=message.get("stack") or payload or message.get("description") or message)
-
- def detached(reason, crash=None):
- emit("detached", message=reason, crash=bool(crash))
- stopped.set()
def wait_for_stop():
for line in sys.stdin:
@@ -109,39 +274,8 @@ def wait_for_stop():
signal.signal(signal.SIGINT, lambda *_: stopped.set())
signal.signal(signal.SIGTERM, lambda *_: stopped.set())
threading.Thread(target=wait_for_stop, daemon=True).start()
- emit("log", message="Experimental Java hooks: Android 16 ART/GC crashes were observed upstream. Native TLS/QUIC stacks are not covered.")
- try:
- pid = device.spawn([args.package])
- session = device.attach(pid)
- session.on("detached", detached)
- script = session.create_script(source)
- script.on("message", receive)
- script.load()
- device.resume(pid)
- resumed = True
- if not ready.wait(30):
- raise RuntimeError("Java hooks did not become ready within 30 seconds. " + "; ".join(failure[-2:]))
- if failure:
- raise RuntimeError("A hook failed during startup: " + "; ".join(failure[-2:]))
- emit("ready", pid=pid, version=frida.__version__, profiles=args.profile or ["proxy", "certificate", "unpinning"])
- stopped.wait()
- finally:
- if script:
- try:
- script.unload()
- except Exception:
- pass
- if session:
- try:
- session.detach()
- except Exception:
- pass
- if pid and not resumed:
- try:
- device.kill(pid)
- except Exception:
- pass
- emit("stopped", message="Frida hooks detached; the selected application was left running")
+ emit("log", message="Experimental Scope Java hooks; Stop ends the spawned app. Native TLS/QUIC and custom pinners are not covered.")
+ run_session(device, args.package, source, profiles, frida.__version__, stopped, process_not_found=(frida.ProcessNotFoundError,), cancellable_factory=frida.Cancellable)
if __name__ == "__main__":
diff --git a/resources/device/tests/test_frida_worker.py b/resources/device/tests/test_frida_worker.py
new file mode 100644
index 0000000..840e776
--- /dev/null
+++ b/resources/device/tests/test_frida_worker.py
@@ -0,0 +1,179 @@
+"""Worker contract tests; fake Frida calls are not Android coverage."""
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+import unittest
+from unittest import mock
+
+SOURCE = Path(__file__).resolve().parents[1] / "frida_worker.py"
+SPEC = importlib.util.spec_from_file_location("scope_frida_worker", SOURCE)
+WORKER = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(WORKER)
+PEM = "-----BEGIN CERTIFICATE-----\nYWJj\n-----END CERTIFICATE-----"
+
+
+class WorkerTests(unittest.TestCase):
+ def test_profiles_are_shared_normalized_and_unsupported_fail(self):
+ self.assertTrue(hasattr(WORKER, "normalize_profiles"), "worker provides normalized profile contract")
+ self.assertEqual(WORKER.normalize_profiles([]), ["proxy", "certificate", "unpinning"])
+ self.assertEqual(WORKER.normalize_profiles(["certificate", "proxy", "certificate"]), ["proxy", "certificate"])
+ for profiles in [["root"], ["system-ca"], ["unpinning"], ["unknown"], "proxy", [None]]:
+ with self.assertRaises(ValueError):
+ WORKER.normalize_profiles(profiles)
+
+ def test_builder_serializes_data_and_loads_only_owned_engine_and_bridge(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ ca = root / "ca.pem"
+ ca.write_text(PEM)
+ (root / "frida-java-bridge.js").write_text("// bridge\n")
+ (root / "scope-engine.js").write_text("globalThis.ScopeJavaEngine = {start(c) { globalThis.observed = c; }};\n")
+ source = WORKER.build_script(root, ca, 17890, ["proxy"])
+ result = subprocess.run(["node", "-e", source + "\nconsole.log(JSON.stringify(globalThis.observed))"], capture_output=True, text=True, check=True)
+ self.assertEqual(json.loads(result.stdout), {"caPem": PEM, "proxyHost": "127.0.0.1", "proxyPort": 17890, "profiles": ["proxy"]})
+ ca.write_text(PEM + "`;globalThis.pwned=true;//")
+ with self.assertRaises(ValueError):
+ WORKER.build_script(root, ca, 17890, ["proxy"])
+ ca.write_text(PEM)
+ for port in [0, 65536, True, "17890;evil()"]:
+ with self.assertRaises(ValueError):
+ WORKER.build_script(root, ca, port, ["proxy"])
+
+ def test_ready_gate_requires_engine_exact_profiles_and_real_capabilities(self):
+ self.assertTrue(hasattr(WORKER, "validate_ready"), "worker validates installed capabilities")
+ good = {"kind": "scope-hooks-ready", "engine": "scope-java-v1", "profiles": ["proxy"], "capabilities": {"proxy": ["java-url"]}}
+ self.assertEqual(WORKER.validate_ready(good, ["proxy"]), good)
+ for changes in [{"engine": "other"}, {"profiles": []}, {"capabilities": {}}, {"capabilities": {"proxy": []}}, {"capabilities": {"proxy": ["not-implemented"]}}]:
+ with self.assertRaises(ValueError):
+ WORKER.validate_ready({**good, **changes}, ["proxy"])
+
+ def test_owned_cleanup_kills_exact_pid_before_unload_and_detach(self):
+ self.assertTrue(hasattr(WORKER, "cleanup_owned"), "worker terminates its instrumented PID")
+ calls = []
+ device = mock.Mock()
+ device.kill.side_effect = lambda pid: calls.append(("kill", pid))
+ script = mock.Mock()
+ script.unload.side_effect = lambda: calls.append(("unload",))
+ session = mock.Mock()
+ session.detach.side_effect = lambda: calls.append(("detach",))
+ WORKER.cleanup_owned(device, 427, script, session, timeout=0.2)
+ self.assertEqual(calls, [("kill", 427), ("unload",), ("detach",)])
+ calls.clear()
+ WORKER.cleanup_owned(device, None, None, None, timeout=0.2)
+ self.assertEqual(calls, [])
+
+ def test_cleanup_errors_and_timeouts_are_visible_and_bounded(self):
+ self.assertTrue(hasattr(WORKER, "cleanup_owned"))
+ device, script, session = mock.Mock(), mock.Mock(), mock.Mock()
+ device.kill.side_effect = RuntimeError("permission denied")
+ with self.assertRaisesRegex(RuntimeError, "kill.*permission denied"):
+ WORKER.cleanup_owned(device, 427, script, session, timeout=0.05)
+ script.unload.assert_called_once()
+ session.detach.assert_called_once()
+ device.kill.side_effect = lambda _pid: time.sleep(0.3)
+ start = time.monotonic()
+ with self.assertRaisesRegex(RuntimeError, "kill.*timed out"):
+ WORKER.cleanup_owned(device, 427, None, None, timeout=0.02)
+ self.assertLess(time.monotonic() - start, 0.2)
+
+ def test_cli_rejects_unsupported_profile_before_import_or_device_contact(self):
+ with mock.patch.object(WORKER.sys, "argv", ["frida_worker", "--profile", "root"]):
+ with self.assertRaisesRegex(ValueError, "profile"):
+ WORKER.main()
+
+class SessionTests(unittest.TestCase):
+ def fixture(self, behavior):
+ calls = []
+ device, session, script = mock.Mock(), mock.Mock(), mock.Mock()
+ device.spawn.side_effect = lambda argv: calls.append(("spawn", argv)) or 427
+ device.attach.return_value = session
+ session.create_script.return_value = script
+ callback = []
+ script.on.side_effect = lambda _name, handler: callback.append(handler)
+ script.load.side_effect = lambda: behavior(callback[0])
+ device.kill.side_effect = lambda pid: calls.append(("kill", pid))
+ script.unload.side_effect = lambda: calls.append(("unload",))
+ session.detach.side_effect = lambda: calls.append(("detach",))
+ return device, session, script, calls
+
+ def test_stop_before_ready_cleans_owned_suspended_process_without_ready(self):
+ stopped = WORKER.threading.Event()
+ device, session, script, calls = self.fixture(lambda _cb: stopped.set())
+ output = []
+ with mock.patch.object(WORKER, "emit", side_effect=lambda kind, **fields: output.append({"kind": kind, **fields})):
+ start = time.monotonic()
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, ready_timeout=30, cleanup_timeout=0.1)
+ self.assertLess(time.monotonic() - start, 0.5)
+ self.assertEqual(calls, [("spawn", ["app.test"]), ("kill", 427), ("unload",), ("detach",)])
+ device.resume.assert_not_called()
+ self.assertFalse(any(row["kind"] == "ready" for row in output))
+ self.assertEqual(output[0], {"kind": "spawned", "engine": "scope-java-v1", "pid": 427})
+ self.assertEqual(output[-1]["processTerminated"], True)
+ self.assertEqual(output[-1]["pid"], 427)
+
+ def test_late_ready_cannot_revive_hook_error_and_startup_failure_kills_pid(self):
+ stopped = WORKER.threading.Event()
+ def behavior(callback):
+ callback({"type": "send", "payload": {"kind": "scope-hooks-error", "message": "no pinner"}}, None)
+ callback({"type": "send", "payload": {"kind": "scope-hooks-ready", "engine": "scope-java-v1", "profiles": ["proxy"], "capabilities": {"proxy": ["java-url"]}}}, None)
+ device, session, script, calls = self.fixture(behavior)
+ output = []
+ with mock.patch.object(WORKER, "emit", side_effect=lambda kind, **fields: output.append({"kind": kind, **fields})):
+ with self.assertRaisesRegex(RuntimeError, "no pinner"):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, cleanup_timeout=0.1)
+ self.assertIn(("kill", 427), calls)
+ self.assertFalse(any(row["kind"] == "ready" for row in output))
+
+ def test_failed_cleanup_never_emits_clean_stopped(self):
+ stopped = WORKER.threading.Event()
+ device, session, script, calls = self.fixture(lambda _cb: stopped.set())
+ device.kill.side_effect = RuntimeError("cannot kill")
+ output = []
+ with mock.patch.object(WORKER, "emit", side_effect=lambda kind, **fields: output.append({"kind": kind, **fields})):
+ with self.assertRaisesRegex(RuntimeError, "cannot kill"):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, cleanup_timeout=0.1)
+ self.assertFalse(any(row["kind"] == "stopped" for row in output))
+
+class DestroyedCleanupTests(unittest.TestCase):
+ def test_already_destroyed_script_and_detached_session_need_no_second_cleanup(self):
+ device, script, session = mock.Mock(), mock.Mock(), mock.Mock()
+ script.is_destroyed = True
+ session.is_detached = True
+ script.unload.side_effect = RuntimeError("script has been destroyed")
+ session.detach.side_effect = RuntimeError("already detached")
+ WORKER.cleanup_owned(device, 427, script, session, timeout=0.1)
+ device.kill.assert_called_once_with(427)
+ script.unload.assert_not_called()
+ session.detach.assert_not_called()
+
+class StartupCancellationTests(unittest.TestCase):
+ fixture = SessionTests.fixture
+ def test_stop_cancels_inflight_script_load_then_kills_only_owned_pid(self):
+ stopped = WORKER.threading.Event()
+ cancelled = WORKER.threading.Event()
+ class Cancellable:
+ def __enter__(self):
+ return self
+ def __exit__(self, *_args):
+ pass
+ def cancel(self):
+ cancelled.set()
+ device, session, script, calls = self.fixture(lambda _cb: None)
+ def blocked_load():
+ stopped.set()
+ if not cancelled.wait(0.5):
+ raise RuntimeError("startup was not cancelled")
+ raise RuntimeError("operation cancelled")
+ script.load.side_effect = blocked_load
+ with mock.patch.object(WORKER, "emit"):
+ with self.assertRaisesRegex(RuntimeError, "operation cancelled"):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, cleanup_timeout=0.1, cancellable_factory=Cancellable)
+ self.assertTrue(cancelled.is_set())
+ self.assertIn(("kill", 427), calls)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/resources/frida/scope-engine.js b/resources/frida/scope-engine.js
new file mode 100644
index 0000000..8885432
--- /dev/null
+++ b/resources/frida/scope-engine.js
@@ -0,0 +1,299 @@
+/* Scope-owned Java interception engine. Uses the documented Frida Java API.
+ * Coverage is deliberately limited to java.net.URL, SSLContext and OkHttp3.
+ * Method rollback cannot revoke already-created clients: the worker must end
+ * its spawned process on Stop or failure. No hostname verifier is replaced.
+ */
+(function (root) {
+ 'use strict';
+ const PROFILES = ['proxy', 'certificate', 'unpinning'];
+ const REQUIRED = { proxy: 'java-url', certificate: 'sslcontext-scope-ca', unpinning: 'okhttp3-certificate-pinner' };
+
+ function normalizeProfiles(profiles) {
+ if (!Array.isArray(profiles) || profiles.some(p => !PROFILES.includes(p))) {
+ throw new Error('Unsupported hook profile; supported: proxy, certificate, unpinning');
+ }
+ const selected = profiles.length ? PROFILES.filter(p => profiles.includes(p)) : PROFILES.slice();
+ if (selected.includes('unpinning') && !selected.includes('certificate')) {
+ throw new Error('unpinning profile requires certificate');
+ }
+ return selected;
+ }
+
+ function createPolicy(validateScopeChain) {
+ function isScope(chain, context) {
+ try { validateScopeChain(chain, context); return true; } catch (_) { return false; }
+ }
+ return {
+ checkPins(chain, original) { if (!isScope(chain)) return original(); },
+ checkTrust(chain, original, context) {
+ try { return original(); } catch (rejection) {
+ if (!isScope(chain, context)) throw rejection;
+ }
+ }
+ };
+ }
+
+ function createEngine(config, adapter) {
+ const profiles = normalizeProfiles(config.profiles);
+ const undo = [];
+ const transaction = {
+ hook(method, implementation) {
+ const previous = method.implementation;
+ // Register undo first: a failing setter can have partially installed.
+ undo.push(() => { method.implementation = previous; });
+ method.implementation = implementation;
+ }
+ };
+ function rollback() {
+ const errors = [];
+ while (undo.length) {
+ try { undo.pop()(); } catch (error) { errors.push(String(error)); }
+ }
+ if (errors.length) throw new Error('Hook rollback failed: ' + errors.join('; ').slice(0, 1500));
+ }
+ let installed = false;
+ return {
+ rollback,
+ install() {
+ if (installed) throw new Error('Engine already installed');
+ const capabilities = {};
+ try {
+ for (const profile of profiles) {
+ const name = 'install' + profile[0].toUpperCase() + profile.slice(1);
+ const result = adapter[name](transaction);
+ if (!Array.isArray(result) || !result.includes(REQUIRED[profile])) {
+ throw new Error('Missing installed capability for profile ' + profile);
+ }
+ capabilities[profile] = result;
+ }
+ installed = true;
+ return { kind: 'scope-hooks-ready', engine: 'scope-java-v1', profiles, capabilities };
+ } catch (error) {
+ try { rollback(); } catch (cleanupError) {
+ throw new Error(String(error) + '; ' + String(cleanupError));
+ }
+ throw error;
+ }
+ }
+ };
+ }
+
+ function createJavaAdapter(Java, config) {
+ let trust;
+ function use(name) {
+ try { return Java.use(name); } catch (error) {
+ throw new Error('Required Java class unavailable: ' + name + ': ' + String(error).slice(0, 300));
+ }
+ }
+ function overload(klass, method, signature) {
+ if (!klass[method]) throw new Error('Required method unavailable: ' + method);
+ try { return klass[method].overload(...signature); } catch (error) {
+ throw new Error('Required overload unavailable: ' + method + '(' + signature.join(', ') + '): ' + String(error).slice(0, 300));
+ }
+ }
+ function trustState() {
+ if (trust) return trust;
+ const X509 = use('javax.net.ssl.X509TrustManager');
+ const Certificate = use('java.security.cert.X509Certificate');
+ const Factory = use('javax.net.ssl.TrustManagerFactory');
+ const Store = use('java.security.KeyStore');
+ const bytes = use('java.lang.String').$new(config.caPem).getBytes('UTF-8');
+ const stream = use('java.io.ByteArrayInputStream').$new(bytes);
+ const ca = Java.cast(use('java.security.cert.CertificateFactory').getInstance('X.509').generateCertificate(stream), Certificate);
+ ca.checkValidity();
+ if (ca.getBasicConstraints() < 0) throw new Error('Scope certificate must be a CA');
+ const store = Store.getInstance(Store.getDefaultType());
+ store.load(null, null); // Empty, never the Android/system trust store.
+ store.setCertificateEntry('scope', ca);
+ const factory = Factory.getInstance(Factory.getDefaultAlgorithm());
+ factory.init(store);
+ const managers = factory.getTrustManagers();
+ let manager;
+ for (let i = 0; i < managers.length; i++) {
+ if (X509.class.isInstance(managers[i])) { manager = Java.cast(managers[i], X509); break; }
+ }
+ if (!manager) throw new Error('No X509 trust manager for isolated Scope CA');
+ let Extended;
+ try { Extended = Java.use('javax.net.ssl.X509ExtendedTrustManager'); } catch (_) { /* Android before API 24. */ }
+ trust = { manager, ca, Factory, X509, Certificate, Extended };
+ return trust;
+ }
+ function certificates(chain) {
+ if (!chain) throw new Error('Peer chain is empty');
+ const values = typeof chain.toArray === 'function' ? chain.toArray() : chain;
+ if (!values.length) throw new Error('Peer chain is empty');
+ const { Certificate } = trustState();
+ const result = [];
+ for (let i = 0; i < values.length; i++) {
+ if (!Certificate.class.isInstance(values[i])) throw new Error('Peer chain contains a non-X509 certificate');
+ result.push(Java.cast(values[i], Certificate));
+ }
+ return result;
+ }
+ function validateScopeChain(chain, context) {
+ const state = trustState();
+ const values = certificates(chain);
+ if (values[0].getBasicConstraints() >= 0) throw new Error('Peer chain must start with a server leaf certificate');
+ // Check every supplied certificate, including trust anchors. Trust manager
+ // performs path construction, signature, usage and algorithm validation.
+ state.ca.checkValidity();
+ values.forEach(cert => cert.checkValidity());
+ const authType = context ? context.authType : String(values[0].getPublicKey().getAlgorithm());
+ const peer = Java.array('java.security.cert.X509Certificate', values);
+ if (context && context.type) {
+ if (!state.Extended || !state.Extended.class.isInstance(state.manager)) throw new Error('Isolated Scope manager cannot preserve extended TLS context');
+ const manager = Java.cast(state.manager, state.Extended);
+ manager.checkServerTrusted.overload('[Ljava.security.cert.X509Certificate;', 'java.lang.String', context.type).call(manager, peer, authType, context.transport);
+ } else {
+ state.manager.checkServerTrusted(peer, authType);
+ }
+ }
+ const policy = createPolicy(validateScopeChain);
+
+ function installCertificate(transaction) {
+ const state = trustState();
+ const init = overload(use('javax.net.ssl.SSLContext'), 'init', ['[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom']);
+ function wrapperClass(extended) {
+ const methods = {
+ getAcceptedIssuers() {
+ const original = this.delegate.value.getAcceptedIssuers();
+ return Java.array('java.security.cert.X509Certificate', Array.from(original).concat([state.ca]));
+ }
+ };
+ for (const name of ['checkClientTrusted', 'checkServerTrusted']) {
+ const implementations = [];
+ for (const type of extended ? [null, 'java.net.Socket', 'javax.net.ssl.SSLEngine'] : [null]) {
+ implementations.push({
+ returnType: 'void',
+ argumentTypes: ['[Ljava.security.cert.X509Certificate;', 'java.lang.String'].concat(type ? [type] : []),
+ implementation(chain, authType, transport) {
+ const delegate = Java.cast(this.delegate.value, extended ? state.Extended : state.X509);
+ const original = () => type ? delegate[name](chain, authType, transport) : delegate[name](chain, authType);
+ if (name === 'checkClientTrusted') return original();
+ return policy.checkTrust(chain, original, { authType, type, transport });
+ }
+ });
+ }
+ methods[name] = implementations;
+ }
+ return Java.registerClass({
+ name: extended ? 'dev.scope.runtime.ScopeExtendedTrustManager' : 'dev.scope.runtime.ScopeTrustManager',
+ ...(extended ? { superClass: state.Extended } : { implements: [state.X509] }),
+ fields: { delegate: 'javax.net.ssl.X509TrustManager' },
+ methods
+ });
+ }
+ const BasicWrapper = wrapperClass(false);
+ const ExtendedWrapper = state.Extended && state.Extended.class.isInstance(state.manager) ? wrapperClass(true) : null;
+ transaction.hook(init, function (keys, managers, random) {
+ if (managers === null) {
+ const defaults = state.Factory.getInstance(state.Factory.getDefaultAlgorithm());
+ // Select the KeyStore overload explicitly; null otherwise ambiguous.
+ defaults.init.overload('java.security.KeyStore').call(defaults, null);
+ managers = defaults.getTrustManagers();
+ }
+ let count = 0;
+ const wrapped = Array.from(managers, manager => {
+ if (!state.X509.class.isInstance(manager)) return manager;
+ const extended = state.Extended && state.Extended.class.isInstance(manager);
+ if (extended && !ExtendedWrapper) throw new Error('Cannot preserve X509ExtendedTrustManager context with this Scope manager');
+ const wrapper = (extended ? ExtendedWrapper : BasicWrapper).$new();
+ wrapper.delegate.value = Java.cast(manager, state.X509);
+ count++;
+ return wrapper;
+ });
+ if (!count) throw new Error('SSLContext has no X509 trust manager to extend');
+ return init.call(this, keys, Java.array('javax.net.ssl.TrustManager', wrapped), random);
+ });
+ return ['sslcontext-scope-ca'];
+ }
+
+ function installProxy(transaction) {
+ const URL = use('java.net.URL');
+ const Proxy = use('java.net.Proxy');
+ const address = use('java.net.InetSocketAddress').$new(config.proxyHost, config.proxyPort);
+ const proxy = Proxy.$new(use('java.net.Proxy$Type').HTTP.value, address);
+ const plain = overload(URL, 'openConnection', []);
+ const explicit = overload(URL, 'openConnection', ['java.net.Proxy']);
+ function http(url) { return ['http', 'https'].includes(String(url.getProtocol())); }
+ transaction.hook(plain, function () { return http(this) ? explicit.call(this, proxy) : plain.call(this); });
+ transaction.hook(explicit, function (selected) { return explicit.call(this, http(this) ? proxy : selected); });
+ const capabilities = ['java-url'];
+ let Builder;
+ try { Builder = Java.use('okhttp3.OkHttpClient$Builder'); } catch (_) { return capabilities; }
+ const build = overload(Builder, 'build', []);
+ const setProxy = overload(Builder, 'proxy', ['java.net.Proxy']);
+ transaction.hook(build, function () { setProxy.call(this, proxy); return build.call(this); });
+ capabilities.push('okhttp3-proxy');
+ return capabilities;
+ }
+
+ function installUnpinning(transaction) {
+ trustState();
+ const Pinner = use('okhttp3.CertificatePinner');
+ let count = 0;
+ let Supplier;
+ for (const name of ['check', 'check$okhttp']) {
+ if (!Pinner[name]) continue;
+ for (const method of Pinner[name].overloads) {
+ const args = method.argumentTypes.map(type => type.className);
+ const ordinary = name === 'check' && args.length === 2 && args[0] === 'java.lang.String' && ['java.util.List', '[Ljava.security.cert.Certificate;'].includes(args[1]);
+ const lazy = name === 'check$okhttp' && args.length === 2 && args[0] === 'java.lang.String' && args[1] === 'kotlin.jvm.functions.Function0';
+ if (!ordinary && !lazy) throw new Error('Unsupported OkHttp pinner overload: ' + name + '(' + args.join(', ') + ')');
+ if (lazy && !Supplier) {
+ Supplier = Java.registerClass({
+ name: 'dev.scope.runtime.ScopePeerChainSupplier',
+ implements: [use('kotlin.jvm.functions.Function0')],
+ fields: { delegate: 'kotlin.jvm.functions.Function0', chain: 'java.lang.Object', evaluated: 'boolean' },
+ methods: {
+ invoke: {
+ returnType: 'java.lang.Object', argumentTypes: [],
+ implementation() {
+ if (!this.evaluated.value) {
+ this.chain.value = this.delegate.value.invoke();
+ this.evaluated.value = true;
+ }
+ return this.chain.value;
+ }
+ }
+ }
+ });
+ }
+ transaction.hook(method, function (host, peer) {
+ if (lazy) {
+ const supplier = Supplier.$new();
+ supplier.delegate.value = peer;
+ // Let the original decide whether pins need a chain at all. Cache
+ // its actual input so a second supplier result cannot grant trust.
+ try { return method.call(this, host, supplier); } catch (rejection) {
+ if (!supplier.evaluated.value) throw rejection;
+ const chain = Java.cast(supplier.chain.value, use('java.util.List'));
+ return policy.checkPins(chain, () => { throw rejection; });
+ }
+ }
+ return policy.checkPins(peer, () => method.call(this, host, peer));
+ });
+ count++;
+ }
+ }
+ if (!count) throw new Error('No supported okhttp3.CertificatePinner overloads');
+ return ['okhttp3-certificate-pinner'];
+ }
+ return { validateScopeChain, installProxy, installCertificate, installUnpinning };
+ }
+
+ function start(config) {
+ try {
+ normalizeProfiles(config.profiles);
+ if (config.proxyHost !== '127.0.0.1' || !Number.isInteger(config.proxyPort) || config.proxyPort < 1 || config.proxyPort > 65535) throw new Error('Invalid loopback proxy configuration');
+ if (!root.Java || !root.Java.available) throw new Error('Frida Java runtime is unavailable');
+ root.Java.perform(() => {
+ try { root.send(createEngine(config, createJavaAdapter(root.Java, config)).install()); }
+ catch (error) { root.send({ kind: 'scope-hooks-error', engine: 'scope-java-v1', message: String(error).slice(0, 2000) }); }
+ });
+ } catch (error) { root.send({ kind: 'scope-hooks-error', engine: 'scope-java-v1', message: String(error).slice(0, 2000) }); }
+ }
+ const api = { normalizeProfiles, createPolicy, createEngine, createJavaAdapter, start };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else root.ScopeJavaEngine = api;
+})(globalThis);
diff --git a/resources/frida/tests/scope-engine.test.cjs b/resources/frida/tests/scope-engine.test.cjs
new file mode 100644
index 0000000..b66c3a2
--- /dev/null
+++ b/resources/frida/tests/scope-engine.test.cjs
@@ -0,0 +1,254 @@
+'use strict';
+// Java/Frida doubles exercise Scope policy and API wiring, not Android execution.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+let engine;
+try {
+ const context = { module: { exports: {} } };
+ vm.runInNewContext(fs.readFileSync(require('node:path').join(__dirname, '../scope-engine.js'), 'utf8'), context);
+ engine = context.module.exports;
+} catch (error) { if (error.code !== 'ENOENT') throw error; }
+const api = () => { assert.ok(engine, 'Scope-owned Java engine is implemented'); return engine; };
+const plain = value => JSON.parse(JSON.stringify(value));
+
+test('profiles default, deduplicate and reject unsupported or missing dependencies', () => {
+ const { normalizeProfiles } = api();
+ assert.deepEqual(plain(normalizeProfiles([])), ['proxy', 'certificate', 'unpinning']);
+ assert.deepEqual(plain(normalizeProfiles(['certificate', 'proxy', 'certificate'])), ['proxy', 'certificate']);
+ for (const value of [['root'], ['system-ca'], ['other'], ['unpinning'], 'proxy', [null]]) {
+ assert.throws(() => normalizeProfiles(value), /profile|certificate/i);
+ }
+});
+
+test('pins skip only a chain accepted by the isolated CA validator; preserve original rejection identity', () => {
+ const { createPolicy } = api();
+ const valid = {}, unrelated = {}, expired = {}, malformed = {};
+ const rejection = new Error('original pin mismatch');
+ const checked = [];
+ const policy = createPolicy(chain => { checked.push(chain); if (chain !== valid) throw new Error('PKIX rejection'); });
+ let originalCalls = 0;
+ const original = () => { originalCalls++; throw rejection; };
+ assert.equal(policy.checkPins(valid, original), undefined);
+ for (const chain of [unrelated, expired, malformed, null]) {
+ assert.throws(() => policy.checkPins(chain, original), error => error === rejection);
+ }
+ assert.equal(originalCalls, 4);
+ assert.equal(checked.length, 5);
+});
+
+test('trust delegates original decisions and preserves original errors for invalid Scope chains', () => {
+ const { createPolicy } = api();
+ const valid = {}, rejection = new Error('original trust rejection');
+ const policy = createPolicy(chain => { if (chain !== valid) throw new Error('invalid'); });
+ assert.equal(policy.checkTrust({}, () => 'original acceptance'), 'original acceptance');
+ assert.equal(policy.checkTrust(valid, () => { throw rejection; }), undefined);
+ assert.throws(() => policy.checkTrust({}, () => { throw rejection; }), error => error === rejection);
+});
+
+test('missing requested capabilities fail transactionally and restore earlier hooks', () => {
+ const { createEngine } = api();
+ const method = { implementation: 'original' };
+ const adapter = {
+ installProxy(transaction) { transaction.hook(method, () => 'proxy'); return ['java-url']; },
+ installCertificate() { throw new Error('SSLContext.init overload missing'); },
+ };
+ assert.throws(() => createEngine({ profiles: ['proxy', 'certificate'] }, adapter).install(), /SSLContext.init/);
+ assert.equal(method.implementation, 'original');
+ assert.throws(() => createEngine({ profiles: ['proxy'] }, { installProxy() { return []; } }).install(), /capabilit/i);
+});
+
+test('readiness reports exactly installed requested capabilities and rollback is explicit', () => {
+ const { createEngine } = api();
+ const method = { implementation: null };
+ const instance = createEngine({ profiles: ['proxy'] }, {
+ installProxy(transaction) { transaction.hook(method, () => 'proxy'); return ['java-url']; },
+ });
+ assert.deepEqual(plain(instance.install()), { kind: 'scope-hooks-ready', engine: 'scope-java-v1', profiles: ['proxy'], capabilities: { proxy: ['java-url'] } });
+ instance.rollback();
+ assert.equal(method.implementation, null);
+});
+
+function javaFixture() {
+ const calls = [];
+ const ca = { checkValidity() { calls.push('ca-valid'); }, getBasicConstraints() { return 1; } };
+ const scopeTrust = { checkServerTrusted(chain, auth) { calls.push(['validate', chain, auth]); if (chain.some(c => c.unrelated)) throw new Error('bad signature'); } };
+ const x509 = { class: { isInstance(value) { return value === scopeTrust; } } };
+ const store = { load(stream, pass) { calls.push(['empty-store', stream, pass]); }, setCertificateEntry(name, cert) { calls.push(['anchor', name, cert]); } };
+ const classes = {
+ 'java.lang.String': { $new(value) { return { getBytes() { return value; } }; } },
+ 'java.io.ByteArrayInputStream': { $new() { return {}; } },
+ 'java.security.cert.CertificateFactory': { getInstance() { return { generateCertificate() { return ca; } }; } },
+ 'java.security.KeyStore': { getDefaultType() { return 'BKS'; }, getInstance() { return store; } },
+ 'javax.net.ssl.TrustManagerFactory': { getDefaultAlgorithm() { return 'PKIX'; }, getInstance() { return { init(value) { calls.push(['tm-init', value]); }, getTrustManagers() { return [scopeTrust]; } }; } },
+ 'javax.net.ssl.X509TrustManager': x509,
+ 'java.security.cert.X509Certificate': { class: { isInstance(value) { return Boolean(value && value.checkValidity); } } },
+ };
+ const Java = { use(name) { if (!classes[name]) throw new Error('Missing class ' + name); return classes[name]; }, cast(value) { return value; }, array(type, value) { return value; } };
+ return { Java, calls, ca, scopeTrust, store, classes };
+}
+function leaf(options = {}) { return { checkValidity() { if (options.expired) throw new Error('expired'); }, getBasicConstraints() { return -1; }, getPublicKey() { return { getAlgorithm() { return 'RSA'; } }; }, ...options }; }
+
+test('Java validator uses empty keystore with only Scope CA and validates peer signatures and validity', () => {
+ const { createJavaAdapter } = api();
+ const fixture = javaFixture();
+ const adapter = createJavaAdapter(fixture.Java, { caPem: 'public certificate', proxyHost: '127.0.0.1', proxyPort: 17890 });
+ const good = leaf();
+ adapter.validateScopeChain([good]);
+ assert.deepEqual(fixture.calls.find(call => Array.isArray(call) && call[0] === 'empty-store'), ['empty-store', null, null]);
+ assert.deepEqual(fixture.calls.filter(call => Array.isArray(call) && call[0] === 'anchor'), [['anchor', 'scope', fixture.ca]]);
+ assert.deepEqual(plain(fixture.calls.find(call => Array.isArray(call) && call[0] === 'validate')), ['validate', [{}], 'RSA']);
+ assert.throws(() => adapter.validateScopeChain([leaf({ unrelated: true }), fixture.ca]), /bad signature/);
+ assert.throws(() => adapter.validateScopeChain([leaf({ expired: true })]), /expired/);
+ assert.throws(() => adapter.validateScopeChain([fixture.ca]), /leaf/);
+ assert.throws(() => adapter.validateScopeChain([]), /empty|chain/i);
+ assert.throws(() => adapter.validateScopeChain([{}]), /certificate/i);
+});
+
+test('real adapter fails closed on missing Java classes and missing OkHttp pinner', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const fixture = javaFixture();
+ const adapter = createJavaAdapter(fixture.Java, { caPem: 'public certificate', proxyHost: '127.0.0.1', proxyPort: 17890 });
+ assert.throws(() => createEngine({ profiles: ['proxy'] }, adapter).install(), /java.net|Missing class/);
+ assert.throws(() => adapter.installUnpinning({ hook() {} }), /okhttp3.CertificatePinner/);
+});
+
+function method(signature, original = () => undefined) {
+ return { argumentTypes: signature.map(className => ({ className })), implementation: null, call(receiver, ...args) { return original.apply(receiver, args); } };
+}
+function dispatcher(...methods) {
+ return { overloads: methods, overload(...signature) {
+ const found = methods.find(m => JSON.stringify(m.argumentTypes.map(t => t.className)) === JSON.stringify(signature));
+ if (!found) throw new Error('overload missing');
+ return found;
+ } };
+}
+function tlsFixture(extended = false) {
+ const f = javaFixture();
+ const calls = [];
+ const rejection = new Error('original rejected');
+ const original = { extended, getAcceptedIssuers() { return []; }, checkClientTrusted(...args) { calls.push(['client', ...args]); }, checkServerTrusted(...args) { calls.push(['server', ...args]); throw rejection; } };
+ f.classes['javax.net.ssl.X509TrustManager'].class.isInstance = value => value === f.scopeTrust || value === original;
+ f.classes['javax.net.ssl.X509ExtendedTrustManager'] = { class: { isInstance: value => Boolean(value.extended) } };
+ f.scopeTrust.extended = extended;
+ let wrapped;
+ const init = method(['[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom'], function (_keys, managers) { wrapped = managers[0]; });
+ f.classes['javax.net.ssl.SSLContext'] = { init: dispatcher(init) };
+ let definition;
+ f.Java.registerClass = spec => {
+ definition = spec;
+ return { $new() {
+ const object = { delegate: { value: null } };
+ for (const [name, entry] of Object.entries(spec.methods)) {
+ object[name] = Array.isArray(entry) ? (...args) => entry.find(m => m.argumentTypes.length === args.length && (args.length !== 3 || m.argumentTypes[2] === args[2].type)).implementation.apply(object, args) : entry.bind(object);
+ }
+ return object;
+ } };
+ };
+ return { ...f, init, original, calls, rejection, get wrapped() { return wrapped; }, get definition() { return definition; } };
+}
+
+test('SSLContext adds Scope trust while preserving client checks, original errors and issuer inputs', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = tlsFixture();
+ createEngine({ profiles: ['certificate'] }, createJavaAdapter(f.Java, { caPem: 'public certificate' })).install();
+ f.init.implementation(null, [f.original], null);
+ const valid = [leaf()];
+ assert.equal(f.wrapped.checkServerTrusted(valid, 'RSA'), undefined);
+ assert.throws(() => f.wrapped.checkServerTrusted([leaf({ unrelated: true })], 'RSA'), e => e === f.rejection);
+ f.wrapped.checkClientTrusted(valid, 'RSA');
+ assert.equal(f.calls.at(-1)[0], 'client');
+ assert.equal(f.calls.at(-1)[1], valid);
+ assert.equal(f.wrapped.getAcceptedIssuers()[0], f.ca);
+});
+
+test('extended trust preserves socket and engine context and scope context rejection', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = tlsFixture(true);
+ const contexts = [];
+ const basicValidation = f.scopeTrust.checkServerTrusted;
+ f.scopeTrust.checkServerTrusted = dispatcher(
+ method(['[Ljava.security.cert.X509Certificate;', 'java.lang.String'], basicValidation),
+ method(['[Ljava.security.cert.X509Certificate;', 'java.lang.String', 'java.net.Socket'], (_chain, _auth, context) => { contexts.push(context); throw new Error('hostname mismatch'); }),
+ method(['[Ljava.security.cert.X509Certificate;', 'java.lang.String', 'javax.net.ssl.SSLEngine'], (_chain, _auth, context) => { contexts.push(context); })
+ );
+ createEngine({ profiles: ['certificate'] }, createJavaAdapter(f.Java, { caPem: 'public certificate' })).install();
+ f.init.implementation(null, [f.original], null);
+ assert.equal(f.definition.superClass, f.classes['javax.net.ssl.X509ExtendedTrustManager']);
+ const socket = { type: 'java.net.Socket' }, tlsEngine = { type: 'javax.net.ssl.SSLEngine' };
+ assert.throws(() => f.wrapped.checkServerTrusted([leaf()], 'RSA', socket), e => e === f.rejection);
+ assert.equal(f.wrapped.checkServerTrusted([leaf()], 'RSA', tlsEngine), undefined);
+ assert.equal(contexts[0], socket);
+ assert.equal(contexts[1], tlsEngine);
+ assert.equal(f.calls[0][3], socket);
+ assert.equal(f.calls[1][3], tlsEngine);
+});
+
+test('URL proxy hooks redirect HTTP only and rollback restores original implementations', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = javaFixture();
+ const scopeProxy = {};
+ const plainMethod = method([], () => 'original non-http');
+ const explicit = method(['java.net.Proxy'], selected => selected);
+ f.classes['java.net.URL'] = { openConnection: dispatcher(plainMethod, explicit) };
+ f.classes['java.net.Proxy'] = { $new(_type, address) { assert.equal(address.host, '127.0.0.1'); assert.equal(address.port, 17890); return scopeProxy; } };
+ f.classes['java.net.Proxy$Type'] = { HTTP: { value: 'HTTP' } };
+ f.classes['java.net.InetSocketAddress'] = { $new(host, port) { return { host, port }; } };
+ const instance = createEngine({ profiles: ['proxy'] }, createJavaAdapter(f.Java, { proxyHost: '127.0.0.1', proxyPort: 17890 }));
+ instance.install();
+ assert.equal(plainMethod.implementation.call({ getProtocol: () => 'https' }), scopeProxy);
+ assert.equal(plainMethod.implementation.call({ getProtocol: () => 'file' }), 'original non-http');
+ const custom = {};
+ assert.equal(explicit.implementation.call({ getProtocol: () => 'file' }, custom), custom);
+ instance.rollback();
+ assert.equal(plainMethod.implementation, null);
+ assert.equal(explicit.implementation, null);
+});
+
+test('OkHttp actual hook preserves unrelated pin exception and rejects unsupported overload transactionally', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = javaFixture();
+ const rejection = new Error('pin mismatch');
+ const check = method(['java.lang.String', 'java.util.List'], () => { throw rejection; });
+ f.classes['okhttp3.CertificatePinner'] = { check: dispatcher(check) };
+ const adapter = createJavaAdapter(f.Java, { caPem: 'public certificate' });
+ // Certificate profile orchestration is tested separately; install the actual pin adapter here.
+ const instance = createEngine({ profiles: ['certificate', 'unpinning'] }, { installCertificate: () => ['sslcontext-scope-ca'], installUnpinning: adapter.installUnpinning });
+ instance.install();
+ assert.equal(check.implementation('example.test', [leaf()]), undefined);
+ assert.throws(() => check.implementation('example.test', [leaf({ unrelated: true }), f.ca]), e => e === rejection);
+ instance.rollback();
+ f.classes['okhttp3.CertificatePinner'].check.overloads.push(method(['java.lang.String', 'unknown.Chain']));
+ assert.throws(() => instance.install(), /already installed/);
+ const second = createEngine({ profiles: ['certificate', 'unpinning'] }, { installCertificate: () => ['sslcontext-scope-ca'], installUnpinning: adapter.installUnpinning });
+ assert.throws(() => second.install(), /Unsupported OkHttp/);
+ assert.equal(check.implementation, null);
+});
+
+test('Kotlin pin hook validates the exact original supplier result once and preserves no-pin acceptance', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = javaFixture();
+ f.classes['kotlin.jvm.functions.Function0'] = {};
+ f.classes['java.util.List'] = {};
+ f.Java.registerClass = spec => ({ $new() {
+ const object = {};
+ for (const field of Object.keys(spec.fields)) object[field] = { value: null };
+ for (const [name, entry] of Object.entries(spec.methods)) object[name] = (typeof entry === 'function' ? entry : entry.implementation).bind(object);
+ return object;
+ } });
+ const rejection = new Error('pin mismatch');
+ const check = method(['java.lang.String', 'kotlin.jvm.functions.Function0'], function (host, peer) { if (host === 'no-pins.test') return; peer.invoke(); throw rejection; });
+ f.classes['okhttp3.CertificatePinner'] = { 'check$okhttp': dispatcher(check) };
+ const adapter = createJavaAdapter(f.Java, { caPem: 'public certificate' });
+ createEngine({ profiles: ['certificate', 'unpinning'] }, { installCertificate: () => ['sslcontext-scope-ca'], installUnpinning: adapter.installUnpinning }).install();
+ let calls = 0;
+ const peer = { invoke() { calls++; return calls === 1 ? [leaf({ unrelated: true })] : [leaf()]; } };
+ assert.equal(check.implementation('no-pins.test', peer), undefined);
+ assert.equal(calls, 0);
+ assert.throws(() => check.implementation('pinned.test', peer), e => e === rejection);
+ assert.equal(calls, 1);
+ assert.equal(check.implementation('pinned.test', { invoke: () => [leaf()] }), undefined);
+ const supplierError = new Error('chain cleaner rejected');
+ assert.throws(() => check.implementation('pinned.test', { invoke() { throw supplierError; } }), e => e === supplierError);
+});
From 8a06c5bf41a92ead29c1a7c4d9eb721e413ef708 Mon Sep 17 00:00:00 2001
From: Mateusz Dalke <150788324+dalkedev@users.noreply.github.com>
Date: Sat, 7 Nov 2026 08:13:04 +0000
Subject: [PATCH 03/18] fix: preserve Java exceptions and current process
ownership
---
resources/device/frida_worker.py | 81 +++++++++++++++++---
resources/device/tests/test_frida_worker.py | 84 +++++++++++++++++++++
resources/frida/scope-engine.js | 36 ++++++---
resources/frida/tests/scope-engine.test.cjs | 78 +++++++++++++++++--
4 files changed, 251 insertions(+), 28 deletions(-)
diff --git a/resources/device/frida_worker.py b/resources/device/frida_worker.py
index 2dbf8c5..10a932c 100644
--- a/resources/device/frida_worker.py
+++ b/resources/device/frida_worker.py
@@ -86,14 +86,69 @@ def run():
return value
-def cleanup_owned(device, pid, script, session, timeout=3, process_not_found=()):
+class ProcessOwnership:
+ """Current local evidence about one spawn, not an atomic remote PID handle."""
+
+ def __init__(self):
+ self.exited = threading.Event()
+ self.replaced = threading.Event()
+ self.lock = threading.Lock()
+ self.pending_kill = None
+ self.kill_dispatched = False
+ self.kill_uncertain = False
+
+ def observe(self, reason):
+ pending = None
+ with self.lock:
+ if reason == "process-terminated":
+ self.exited.set()
+ elif reason == "process-replaced":
+ self.replaced.set()
+ if self.kill_dispatched:
+ self.kill_uncertain = True
+ pending = self.pending_kill
+ if pending is not None:
+ pending.cancel()
+
+ def kill_current(self, operation, cancellable):
+ try:
+ with cancellable if cancellable is not None else nullcontext():
+ with self.lock:
+ if self.replaced.is_set():
+ raise RuntimeError("Process ownership lost: original image was replaced; refusing kill")
+ if self.exited.is_set():
+ return
+ self.pending_kill = cancellable
+ self.kill_dispatched = True
+ # Check inside the destructive operation's worker thread, rather
+ # than relying on the caller's earlier ownership snapshot.
+ if self.replaced.is_set():
+ raise RuntimeError("Process ownership lost before kill dispatch; refusing kill")
+ if self.exited.is_set():
+ return
+ operation()
+ finally:
+ with self.lock:
+ self.pending_kill = None
+ self.kill_dispatched = False
+ uncertain = self.kill_uncertain
+ if uncertain:
+ raise RuntimeError("Kill completion is unknown after in-flight process replacement; cancellation cannot prove remote outcome")
+
+
+def cleanup_owned(device, pid, script, session, timeout=3, process_not_found=(), ownership=None, cancellable_factory=None):
errors = []
+ ownership = ownership if ownership is not None else ProcessOwnership()
- def kill():
+ def kill_pid():
try:
device.kill(pid)
except process_not_found:
- pass # Frida confirms this process already ended.
+ ownership.observe("process-terminated")
+
+ def kill():
+ cancellable = cancellable_factory() if cancellable_factory else None
+ ownership.kill_current(kill_pid, cancellable)
def unload():
if script.is_destroyed is not True:
@@ -130,10 +185,10 @@ def detach():
def run_session(device, package, source, profiles, version, stopped, ready_timeout=30, cleanup_timeout=3, process_not_found=(), cancellable_factory=None):
profiles = normalize_profiles(profiles)
ready = threading.Event()
- exited = threading.Event()
+ ownership = ProcessOwnership()
failure = deque(maxlen=2)
receipt = []
- replaced = threading.Event()
+ replaced = ownership.replaced
session = script = pid = None
def receive(message, _data):
@@ -159,10 +214,8 @@ def receive(message, _data):
emit("log", message=message.get("stack") or payload or message.get("description") or message)
def detached(reason, crash=None):
- if reason == "process-terminated":
- exited.set()
- elif reason == "process-replaced":
- replaced.set()
+ ownership.observe(reason)
+ if reason == "process-replaced":
failure.append("Owned process image was replaced; termination cannot be confirmed safely")
emit("detached", message=reason, crash=bool(crash))
stopped.set()
@@ -220,13 +273,17 @@ def cancel_startup():
finally:
startup_complete.set()
try:
- cleanup_owned(device, None if exited.is_set() or replaced.is_set() else pid, script, session, cleanup_timeout, process_not_found)
+ cleanup_owned(device, pid, script, session, cleanup_timeout, process_not_found, ownership, cancellable_factory)
except Exception as error:
if primary_error:
raise RuntimeError(str(primary_error) + "; " + str(error)) from primary_error
raise
- if not replaced.is_set() and pid is not None:
- emit("stopped", engine="scope-java-v1", pid=pid, processTerminated=True, message="Owned application process ended; hooks unloaded and session detached")
+ if replaced.is_set():
+ raise RuntimeError("Owned process image was replaced; cleanup outcome is uncertain") from primary_error
+ if not attempted_spawn:
+ emit("stopped", engine="scope-java-v1", pid=None, spawnAttempted=False, cleanupComplete=True, processTerminated=False)
+ elif pid is not None:
+ emit("stopped", engine="scope-java-v1", pid=pid, spawnAttempted=True, cleanupComplete=True, processTerminated=True, message="Owned application process ended; hooks unloaded and session detached")
if primary_error:
raise primary_error
diff --git a/resources/device/tests/test_frida_worker.py b/resources/device/tests/test_frida_worker.py
index 840e776..9723158 100644
--- a/resources/device/tests/test_frida_worker.py
+++ b/resources/device/tests/test_frida_worker.py
@@ -174,6 +174,90 @@ def blocked_load():
self.assertTrue(cancelled.is_set())
self.assertIn(("kill", 427), calls)
+class ReviewedLifecycleTests(unittest.TestCase):
+ fixture = SessionTests.fixture
+
+ def test_pre_spawn_stop_has_explicit_safe_no_spawn_receipt(self):
+ stopped = WORKER.threading.Event()
+ stopped.set()
+ device = mock.Mock()
+ output = []
+ with mock.patch.object(WORKER, "emit", side_effect=lambda kind, **fields: output.append({"kind": kind, **fields})):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped)
+ device.spawn.assert_not_called()
+ self.assertEqual(output, [{"kind": "stopped", "engine": "scope-java-v1", "pid": None, "spawnAttempted": False, "cleanupComplete": True, "processTerminated": False}])
+
+ def test_replacement_before_kill_dispatch_never_kills_new_image(self):
+ stopped = WORKER.threading.Event()
+ device, session, script, calls = self.fixture(lambda _cb: stopped.set())
+ detached = []
+ session.on.side_effect = lambda _name, handler: detached.append(handler)
+ bounded = WORKER.bounded_call
+ def interleave(label, operation, timeout):
+ if label == "kill owned application":
+ detached[0]("process-replaced")
+ return bounded(label, operation, timeout)
+ output = []
+ with mock.patch.object(WORKER, "bounded_call", side_effect=interleave), mock.patch.object(WORKER, "emit", side_effect=lambda kind, **fields: output.append({"kind": kind, **fields})):
+ with self.assertRaisesRegex(RuntimeError, "replaced|ownership"):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, cleanup_timeout=0.1)
+ device.kill.assert_not_called()
+ self.assertFalse(any(row["kind"] == "stopped" for row in output))
+
+ def test_replacement_cancels_dispatched_kill_and_retains_uncertainty(self):
+ stopped = WORKER.threading.Event()
+ cancelled = WORKER.threading.Event()
+ class Cancellable:
+ def __enter__(self): return self
+ def __exit__(self, *_args): pass
+ def cancel(self): cancelled.set()
+ device, session, script, calls = self.fixture(lambda _cb: stopped.set())
+ detached = []
+ session.on.side_effect = lambda _name, handler: detached.append(handler)
+ def kill(_pid):
+ detached[0]("process-replaced")
+ if not cancelled.wait(0.2):
+ raise RuntimeError("pending kill was not cancelled")
+ device.kill.side_effect = kill
+ with mock.patch.object(WORKER, "emit") as output:
+ with self.assertRaisesRegex(RuntimeError, "unknown|uncertain"):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, cleanup_timeout=0.3, cancellable_factory=Cancellable)
+ self.assertTrue(cancelled.is_set())
+ self.assertFalse(any(call.args[0] == "stopped" for call in output.call_args_list))
+
+
+class OwnershipEvidenceTests(unittest.TestCase):
+ fixture = SessionTests.fixture
+
+ def test_attempted_spawn_without_returned_pid_never_claims_no_spawn_cleanup(self):
+ stopped = WORKER.threading.Event()
+ device = mock.Mock()
+ device.spawn.side_effect = RuntimeError("connection lost during spawn")
+ with mock.patch.object(WORKER, "emit") as output:
+ with self.assertRaisesRegex(RuntimeError, "completion is unknown"):
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped)
+ device.kill.assert_not_called()
+ self.assertFalse(any(call.args[0] == "stopped" for call in output.call_args_list))
+
+ def test_observed_exit_before_kill_dispatch_skips_pid_and_acknowledges_termination(self):
+ stopped = WORKER.threading.Event()
+ device, session, script, calls = self.fixture(lambda _cb: stopped.set())
+ detached = []
+ session.on.side_effect = lambda _name, handler: detached.append(handler)
+ bounded = WORKER.bounded_call
+ def interleave(label, operation, timeout):
+ if label == "kill owned application":
+ detached[0]("process-terminated")
+ return bounded(label, operation, timeout)
+ with mock.patch.object(WORKER, "bounded_call", side_effect=interleave), mock.patch.object(WORKER, "emit") as output:
+ WORKER.run_session(device, "app.test", "source", ["proxy"], "17.9.1", stopped, cleanup_timeout=0.1)
+ device.kill.assert_not_called()
+ receipts = [call.kwargs for call in output.call_args_list if call.args[0] == "stopped"]
+ self.assertEqual(len(receipts), 1)
+ self.assertIs(receipts[0]["processTerminated"], True)
+ self.assertIs(receipts[0]["spawnAttempted"], True)
+ self.assertIs(receipts[0]["cleanupComplete"], True)
+
if __name__ == "__main__":
unittest.main()
diff --git a/resources/frida/scope-engine.js b/resources/frida/scope-engine.js
index 8885432..65f6c32 100644
--- a/resources/frida/scope-engine.js
+++ b/resources/frida/scope-engine.js
@@ -19,15 +19,19 @@
return selected;
}
- function createPolicy(validateScopeChain) {
+ function createPolicy(validateScopeChain, rejectionTypes = {}) {
function isScope(chain, context) {
try { validateScopeChain(chain, context); return true; } catch (_) { return false; }
}
return {
- checkPins(chain, original) { if (!isScope(chain)) return original(); },
+ checkPins(chain, original) {
+ try { return original(); } catch (rejection) {
+ if (!rejectionTypes.isPinRejection || !rejectionTypes.isPinRejection(rejection) || !isScope(chain)) throw rejection;
+ }
+ },
checkTrust(chain, original, context) {
try { return original(); } catch (rejection) {
- if (!isScope(chain, context)) throw rejection;
+ if (!rejectionTypes.isCertificateRejection || !rejectionTypes.isCertificateRejection(rejection) || !isScope(chain, context)) throw rejection;
}
}
};
@@ -148,16 +152,30 @@
state.manager.checkServerTrusted(peer, authType);
}
}
- const policy = createPolicy(validateScopeChain);
+ function isJavaException(error, className) {
+ if (!error || !error.$h) return false;
+ try {
+ // The retained bridge attaches the original JNI Throwable handle to
+ // Java invocation errors. Inspect that object, never message text.
+ return use(className).class.isInstance(Java.cast(error.$h, use('java.lang.Throwable')));
+ } catch (_) { return false; }
+ }
+ const policy = createPolicy(validateScopeChain, {
+ isCertificateRejection: error => isJavaException(error, 'java.security.cert.CertificateException'),
+ isPinRejection: error => isJavaException(error, 'javax.net.ssl.SSLPeerUnverifiedException')
+ });
function installCertificate(transaction) {
const state = trustState();
const init = overload(use('javax.net.ssl.SSLContext'), 'init', ['[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom']);
function wrapperClass(extended) {
const methods = {
- getAcceptedIssuers() {
- const original = this.delegate.value.getAcceptedIssuers();
- return Java.array('java.security.cert.X509Certificate', Array.from(original).concat([state.ca]));
+ getAcceptedIssuers: {
+ returnType: '[Ljava.security.cert.X509Certificate;', argumentTypes: [],
+ implementation() {
+ const original = this.delegate.value.getAcceptedIssuers();
+ return Java.array('java.security.cert.X509Certificate', Array.from(original).concat([state.ca]));
+ }
}
};
for (const name of ['checkClientTrusted', 'checkServerTrusted']) {
@@ -168,7 +186,7 @@
argumentTypes: ['[Ljava.security.cert.X509Certificate;', 'java.lang.String'].concat(type ? [type] : []),
implementation(chain, authType, transport) {
const delegate = Java.cast(this.delegate.value, extended ? state.Extended : state.X509);
- const original = () => type ? delegate[name](chain, authType, transport) : delegate[name](chain, authType);
+ const original = () => type ? delegate[name].overload('[Ljava.security.cert.X509Certificate;', 'java.lang.String', type).call(delegate, chain, authType, transport) : delegate[name](chain, authType);
if (name === 'checkClientTrusted') return original();
return policy.checkTrust(chain, original, { authType, type, transport });
}
@@ -266,7 +284,7 @@
// Let the original decide whether pins need a chain at all. Cache
// its actual input so a second supplier result cannot grant trust.
try { return method.call(this, host, supplier); } catch (rejection) {
- if (!supplier.evaluated.value) throw rejection;
+ if (!supplier.evaluated.value || !isJavaException(rejection, 'javax.net.ssl.SSLPeerUnverifiedException')) throw rejection;
const chain = Java.cast(supplier.chain.value, use('java.util.List'));
return policy.checkPins(chain, () => { throw rejection; });
}
diff --git a/resources/frida/tests/scope-engine.test.cjs b/resources/frida/tests/scope-engine.test.cjs
index b66c3a2..b2a9f1d 100644
--- a/resources/frida/tests/scope-engine.test.cjs
+++ b/resources/frida/tests/scope-engine.test.cjs
@@ -12,6 +12,7 @@ try {
} catch (error) { if (error.code !== 'ENOENT') throw error; }
const api = () => { assert.ok(engine, 'Scope-owned Java engine is implemented'); return engine; };
const plain = value => JSON.parse(JSON.stringify(value));
+function javaError(javaType, message) { const error = new Error(message); error.$h = { javaType }; return error; }
test('profiles default, deduplicate and reject unsupported or missing dependencies', () => {
const { normalizeProfiles } = api();
@@ -27,21 +28,21 @@ test('pins skip only a chain accepted by the isolated CA validator; preserve ori
const valid = {}, unrelated = {}, expired = {}, malformed = {};
const rejection = new Error('original pin mismatch');
const checked = [];
- const policy = createPolicy(chain => { checked.push(chain); if (chain !== valid) throw new Error('PKIX rejection'); });
+ const policy = createPolicy(chain => { checked.push(chain); if (chain !== valid) throw new Error('PKIX rejection'); }, { isPinRejection: error => error === rejection });
let originalCalls = 0;
const original = () => { originalCalls++; throw rejection; };
assert.equal(policy.checkPins(valid, original), undefined);
for (const chain of [unrelated, expired, malformed, null]) {
assert.throws(() => policy.checkPins(chain, original), error => error === rejection);
}
- assert.equal(originalCalls, 4);
+ assert.equal(originalCalls, 5);
assert.equal(checked.length, 5);
});
test('trust delegates original decisions and preserves original errors for invalid Scope chains', () => {
const { createPolicy } = api();
const valid = {}, rejection = new Error('original trust rejection');
- const policy = createPolicy(chain => { if (chain !== valid) throw new Error('invalid'); });
+ const policy = createPolicy(chain => { if (chain !== valid) throw new Error('invalid'); }, { isCertificateRejection: error => error === rejection });
assert.equal(policy.checkTrust({}, () => 'original acceptance'), 'original acceptance');
assert.equal(policy.checkTrust(valid, () => { throw rejection; }), undefined);
assert.throws(() => policy.checkTrust({}, () => { throw rejection; }), error => error === rejection);
@@ -77,6 +78,9 @@ function javaFixture() {
const x509 = { class: { isInstance(value) { return value === scopeTrust; } } };
const store = { load(stream, pass) { calls.push(['empty-store', stream, pass]); }, setCertificateEntry(name, cert) { calls.push(['anchor', name, cert]); } };
const classes = {
+ 'java.lang.Throwable': {},
+ 'java.security.cert.CertificateException': { class: { isInstance: value => value.javaType === 'certificate' } },
+ 'javax.net.ssl.SSLPeerUnverifiedException': { class: { isInstance: value => value.javaType === 'pin' } },
'java.lang.String': { $new(value) { return { getBytes() { return value; } }; } },
'java.io.ByteArrayInputStream': { $new() { return {}; } },
'java.security.cert.CertificateFactory': { getInstance() { return { generateCertificate() { return ca; } }; } },
@@ -127,8 +131,12 @@ function dispatcher(...methods) {
function tlsFixture(extended = false) {
const f = javaFixture();
const calls = [];
- const rejection = new Error('original rejected');
+ const rejection = javaError('certificate', 'original rejected');
const original = { extended, getAcceptedIssuers() { return []; }, checkClientTrusted(...args) { calls.push(['client', ...args]); }, checkServerTrusted(...args) { calls.push(['server', ...args]); throw rejection; } };
+ for (const name of ['checkClientTrusted', 'checkServerTrusted']) {
+ const dynamic = original[name];
+ Object.assign(dynamic, dispatcher(...[null, 'java.net.Socket', 'javax.net.ssl.SSLEngine'].map(type => method(['[Ljava.security.cert.X509Certificate;', 'java.lang.String'].concat(type ? [type] : []), dynamic))));
+ }
f.classes['javax.net.ssl.X509TrustManager'].class.isInstance = value => value === f.scopeTrust || value === original;
f.classes['javax.net.ssl.X509ExtendedTrustManager'] = { class: { isInstance: value => Boolean(value.extended) } };
f.scopeTrust.extended = extended;
@@ -141,7 +149,7 @@ function tlsFixture(extended = false) {
return { $new() {
const object = { delegate: { value: null } };
for (const [name, entry] of Object.entries(spec.methods)) {
- object[name] = Array.isArray(entry) ? (...args) => entry.find(m => m.argumentTypes.length === args.length && (args.length !== 3 || m.argumentTypes[2] === args[2].type)).implementation.apply(object, args) : entry.bind(object);
+ object[name] = Array.isArray(entry) ? (...args) => entry.find(m => m.argumentTypes.length === args.length && (args.length !== 3 || m.argumentTypes[2] === args[2].type)).implementation.apply(object, args) : (typeof entry === 'function' ? entry : entry.implementation).bind(object);
}
return object;
} };
@@ -209,7 +217,7 @@ test('URL proxy hooks redirect HTTP only and rollback restores original implemen
test('OkHttp actual hook preserves unrelated pin exception and rejects unsupported overload transactionally', () => {
const { createJavaAdapter, createEngine } = api();
const f = javaFixture();
- const rejection = new Error('pin mismatch');
+ const rejection = javaError('pin', 'pin mismatch');
const check = method(['java.lang.String', 'java.util.List'], () => { throw rejection; });
f.classes['okhttp3.CertificatePinner'] = { check: dispatcher(check) };
const adapter = createJavaAdapter(f.Java, { caPem: 'public certificate' });
@@ -237,7 +245,7 @@ test('Kotlin pin hook validates the exact original supplier result once and pres
for (const [name, entry] of Object.entries(spec.methods)) object[name] = (typeof entry === 'function' ? entry : entry.implementation).bind(object);
return object;
} });
- const rejection = new Error('pin mismatch');
+ const rejection = javaError('pin', 'pin mismatch');
const check = method(['java.lang.String', 'kotlin.jvm.functions.Function0'], function (host, peer) { if (host === 'no-pins.test') return; peer.invoke(); throw rejection; });
f.classes['okhttp3.CertificatePinner'] = { 'check$okhttp': dispatcher(check) };
const adapter = createJavaAdapter(f.Java, { caPem: 'public certificate' });
@@ -252,3 +260,59 @@ test('Kotlin pin hook validates the exact original supplier result once and pres
const supplierError = new Error('chain cleaner rejected');
assert.throws(() => check.implementation('pinned.test', { invoke() { throw supplierError; } }), e => e === supplierError);
});
+
+test('extended accepted issuers registration explicitly implements the X509 array descriptor', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = tlsFixture(true);
+ createEngine({ profiles: ['certificate'] }, createJavaAdapter(f.Java, { caPem: 'public certificate' })).install();
+ const descriptor = f.definition.methods.getAcceptedIssuers;
+ assert.equal(descriptor.returnType, '[Ljava.security.cert.X509Certificate;');
+ assert.deepEqual(plain(descriptor.argumentTypes), []);
+});
+
+test('valid Scope chain cannot suppress unrelated trust or ordinary/lazy pinner errors', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const unrelated = new Error('internal programming failure');
+ const f = tlsFixture();
+ f.original.checkServerTrusted = () => { throw unrelated; };
+ createEngine({ profiles: ['certificate'] }, createJavaAdapter(f.Java, { caPem: 'public certificate' })).install();
+ f.init.implementation(null, [f.original], null);
+ assert.throws(() => f.wrapped.checkServerTrusted([leaf()], 'RSA'), e => e === unrelated);
+ for (const lazy of [false, true]) {
+ const p = javaFixture();
+ p.classes['kotlin.jvm.functions.Function0'] = {};
+ p.classes['java.util.List'] = {};
+ p.Java.registerClass = spec => ({ $new() {
+ const object = {};
+ for (const field of Object.keys(spec.fields)) object[field] = { value: null };
+ for (const [name, entry] of Object.entries(spec.methods)) object[name] = entry.implementation.bind(object);
+ return object;
+ } });
+ const signature = ['java.lang.String', lazy ? 'kotlin.jvm.functions.Function0' : 'java.util.List'];
+ const check = method(signature, (_host, peer) => { if (lazy) peer.invoke(); throw unrelated; });
+ p.classes['okhttp3.CertificatePinner'] = { [lazy ? 'check$okhttp' : 'check']: dispatcher(check) };
+ const adapter = createJavaAdapter(p.Java, { caPem: 'public certificate' });
+ createEngine({ profiles: ['certificate', 'unpinning'] }, { installCertificate: () => ['sslcontext-scope-ca'], installUnpinning: adapter.installUnpinning }).install();
+ assert.throws(() => check.implementation('test', lazy ? { invoke: () => [leaf()] } : [leaf()]), e => e === unrelated);
+ }
+});
+
+test('nullable extended delegation selects exact original Socket and SSLEngine overloads', () => {
+ const { createJavaAdapter, createEngine } = api();
+ const f = tlsFixture(true);
+ const selected = [];
+ for (const name of ['checkClientTrusted', 'checkServerTrusted']) {
+ const legacy = f.original[name];
+ const dynamic = (...args) => legacy(...args); // Baseline dynamic dispatcher cannot distinguish null.
+ Object.assign(dynamic, dispatcher(...['java.net.Socket', 'javax.net.ssl.SSLEngine'].map(type => method(['[Ljava.security.cert.X509Certificate;', 'java.lang.String', type], (_chain, _auth, transport) => { assert.equal(transport, null); selected.push([name, type]); }))));
+ f.original[name] = dynamic;
+ }
+ createEngine({ profiles: ['certificate'] }, createJavaAdapter(f.Java, { caPem: 'public certificate' })).install();
+ f.init.implementation(null, [f.original], null);
+ for (const name of ['checkClientTrusted', 'checkServerTrusted']) {
+ for (const type of ['java.net.Socket', 'javax.net.ssl.SSLEngine']) {
+ f.definition.methods[name].find(entry => entry.argumentTypes[2] === type).implementation.call(f.wrapped, [leaf({ unrelated: true })], 'RSA', null);
+ }
+ }
+ assert.deepEqual(selected, [['checkClientTrusted', 'java.net.Socket'], ['checkClientTrusted', 'javax.net.ssl.SSLEngine'], ['checkServerTrusted', 'java.net.Socket'], ['checkServerTrusted', 'javax.net.ssl.SSLEngine']]);
+});
From 0c59a77909f85fd3670304344554bac5574e312d Mon Sep 17 00:00:00 2001
From: Mateusz Dalke <150788324+dalkedev@users.noreply.github.com>
Date: Sat, 7 Nov 2026 08:15:29 +0000
Subject: [PATCH 04/18] docs: record reviewed engine lifecycle and integration
contract
---
.../plans/2026-11-07-owned-android-engine.md | 14 +++++++-------
.../2026-11-07-owned-android-engine-design.md | 4 ++--
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/docs/superpowers/plans/2026-11-07-owned-android-engine.md b/docs/superpowers/plans/2026-11-07-owned-android-engine.md
index 24558ef..eb30fcc 100644
--- a/docs/superpowers/plans/2026-11-07-owned-android-engine.md
+++ b/docs/superpowers/plans/2026-11-07-owned-android-engine.md
@@ -1,6 +1,6 @@
# Scope-owned Android engine Implementation Plan
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Replace HTTP Toolkit runtime hooks with an original CA-scoped Android interception engine in one independently reviewed PR.
**Architecture:** Original JavaScript hook/policy code runs in the selected app through the existing Python/Frida worker. Rust retains proxy/recovery management and materializes only the new assets. Imported system trust injection and unsupported root profiles are removed explicitly.
@@ -33,16 +33,16 @@
**Files:** Create `resources/frida/scope-engine.js`, `resources/frida/tests/scope-engine.test.cjs`, `resources/device/tests/test_frida_worker.py`; modify `resources/device/frida_worker.py`.
**Interfaces:** Worker `build_script(root, ca, port, profiles) -> str` remains available. `normalize_profiles(profiles) -> list[str]` is shared by the CLI/builder. New JS exports a testable engine factory/policy boundary and runs through documented Frida `Java` APIs. JSON runtime config uses public CA/loopback proxy/normalized profiles. Readiness payload identifies engine `scope-java-v1`, selected profiles and installed capabilities; Python validates it. Existing Rust JSONL `ready/error/stopped/detached` kinds remain compatible.
-- [ ] Write executable JS and Python failing tests for CA-scoped pin decisions, original rejection propagation, original trust delegation, explicit/default/unsupported profiles, missing classes and transactional rollback, hostile config serialization, readiness validation and owned-PID cleanup.
-- [ ] Observe RED with expected missing implementation or wrong existing behavior; record exact commands/output in report.
-- [ ] Implement original engine (small focused helpers in one runtime file), worker JSON config construction, capability-aware ready gate and finite owned-process cleanup; preserve CLI listing/version and bounded diagnostics. Use the actual Frida API, not a production test stub. Any unimplemented requested capability is an error.
-- [ ] Run focused tests plus complete Python device suite; self-review. Do not claim fixture-based adapter tests are device verification.
-- [ ] Commit only Task1 files and report exact RED/GREEN and remaining limitations.
+- [x] Write executable JS and Python failing tests for CA-scoped pin decisions, original rejection propagation, original trust delegation, explicit/default/unsupported profiles, missing classes and transactional rollback, hostile config serialization, readiness validation and owned-PID cleanup.
+- [x] Observe RED with expected missing implementation or wrong existing behavior; record exact commands/output in report.
+- [x] Implement original engine (small focused helpers in one runtime file), worker JSON config construction, capability-aware ready gate and finite owned-process cleanup; preserve CLI listing/version and bounded diagnostics. Use the actual Frida API, not a production test stub. Any unimplemented requested capability is an error.
+- [x] Run focused tests plus complete Python device suite; self-review. Do not claim fixture-based adapter tests are device verification.
+- [x] Commit only Task1 files and report exact RED/GREEN and remaining limitations.
### Task 2: Application integration, migration and release verification
**Files:** Modify `crates/scope-device/src/lib.rs`, `src/App.tsx`, `README.md`, `docs/ANDROID.md`, `THIRD_PARTY_NOTICES.md`, `.github/workflows/checks.yml`, affected existing tests; remove six imported HTK runtime JS/config files and `resources/device/install-system-ca.template.sh`; retain Frida bridge/licenses. Add `docs/ANDROID_ENGINE.md` with explicit coverage/evidence.
-**Interfaces:** Consume Task1 `scope-engine.js`, `normalize_profiles` and JSONL engine/capability payload. Rust rejects unsupported profiles before ADB/root probes; ASSETS materializes only `frida/scope-engine.js`, existing Frida bridge and worker. Remove automatic system CA installation, preserve cleanup of old recovery ca_hash. UI removes unsupported root option and explains Stop ends the instrumented process.
+**Interfaces:** Consume Task1 `scope-engine.js`, `normalize_profiles` and JSONL engine/capability payload. Rust persists worker-spawned target PID/pending-cleanup before readiness where observable and requires explicit successful termination acknowledgment (or conservative proof that the recorded process no longer exists) before clearing recovery; worker timeout/nonzero/missing acknowledgment must remain an error. New recovery fields are backward-compatible serde defaults. Startup failure allows bounded worker teardown before kill. Rust rejects unsupported profiles before ADB/root probes; ASSETS materializes only `frida/scope-engine.js`, existing Frida bridge and worker. Remove automatic system CA installation, preserve cleanup of old recovery ca_hash. UI removes unsupported root option and explains Stop ends the instrumented process.
- [ ] Add regression tests for early unsupported profile rejection, asset construction and legacy owned-CA recovery behavior where affected; observe RED.
- [ ] Wire original engine and remove imported runtime source/injection path. Preserve unrelated Rust proxy and recovery semantics.
diff --git a/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md b/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
index 8504754..b3fee4e 100644
--- a/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
+++ b/docs/superpowers/specs/2026-11-07-owned-android-engine-design.md
@@ -18,7 +18,7 @@ Supported profiles are `proxy`, `certificate`, `unpinning`; an empty selection m
The Python worker validates finite profile, PEM and port inputs before device work. Source construction uses JSON serialization for runtime data, not source-text replacement. The engine reports per-capability installation results. Readiness requires successful installation of every requested profile, not merely entry into Java.perform. Unsupported classes/overloads or hook errors produce actionable bounded diagnostics and no success claim. Installation failure rolls back installed method implementations.
-Process-created proxy/trust objects can outlive method unhooking. Therefore Stop terminates the exact process spawned by this worker, then unloads/detaches; it does not promise that unloading alone restores the running app. It must not kill an unrelated process. Cleanup errors remain visible, and no clean-stop receipt is emitted after failed cleanup. Existing Rust recovery of owned ADB/server state and legacy CA removal remains available after restart.
+Process-created proxy/trust objects can outlive method unhooking. Therefore Stop terminates the exact process spawned by this worker, then unloads/detaches; it does not promise that unloading alone restores the running app. It must not kill an unrelated process. Cleanup errors remain visible, and no clean-stop receipt is emitted after failed cleanup. The worker emits a spawned-PID event before attach and an explicit termination acknowledgment only after cleanup. Rust preserves pending target-process cleanup in recovery records; it must not convert a killed/failed worker into a successful Stop. A saved owned PID can be checked for absence before clearing an interrupted session; ambiguous state is an actionable error, never a reason to kill an arbitrary or reused PID. Existing Rust recovery of owned ADB/server state and legacy CA removal remains available after restart.
## Compatibility changes
@@ -34,4 +34,4 @@ Real Android/Frida/OkHttp TLS, app-stop restoration and API/ABI compatibility ar
- https://frida.re/docs/javascript-api/#java
- https://developer.android.com/privacy-and-security/security-config
-- https://square.github.io/okhttp/features/https/
+- https://github.com/square/okhttp
From b2967e4c95f50de10686eabf3beec3ae8a1b1351 Mon Sep 17 00:00:00 2001
From: Mateusz Dalke <150788324+dalkedev@users.noreply.github.com>
Date: Sat, 7 Nov 2026 08:29:13 +0000
Subject: [PATCH 05/18] Integrate Scope-owned Android Java engine and
conservative recovery
---
.github/workflows/checks.yml | 1 +
README.md | 6 +-
THIRD_PARTY_NOTICES.md | 40 +-
crates/scope-device/src/diagnostics.rs | 2 +
crates/scope-device/src/lib.rs | 929 ++++++++++++++----
docs/ANDROID.md | 35 +-
docs/ANDROID_ENGINE.md | 36 +
docs/ARCHITECTURE.md | 8 +-
docs/VERIFICATION.md | 4 +
.../device/install-system-ca.template.sh | 93 --
.../android-certificate-unpinning-fallback.js | 267 -----
.../android/android-certificate-unpinning.js | 539 ----------
.../android/android-disable-root-detection.js | 400 --------
.../frida/android/android-proxy-override.js | 98 --
.../android-system-certificate-injection.js | 88 --
resources/frida/config-template.js | 255 -----
src/App.breakpoints.test.tsx | 20 +
src/App.tsx | 24 +-
18 files changed, 856 insertions(+), 1989 deletions(-)
create mode 100644 docs/ANDROID_ENGINE.md
delete mode 100644 resources/device/install-system-ca.template.sh
delete mode 100644 resources/frida/android/android-certificate-unpinning-fallback.js
delete mode 100644 resources/frida/android/android-certificate-unpinning.js
delete mode 100644 resources/frida/android/android-disable-root-detection.js
delete mode 100644 resources/frida/android/android-proxy-override.js
delete mode 100644 resources/frida/android/android-system-certificate-injection.js
delete mode 100644 resources/frida/config-template.js
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 41ff35d..06203f4 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -49,6 +49,7 @@ jobs:
- run: cargo fmt --all -- --check
- run: cargo test --workspace --locked
- run: npm test -- --run
+ - run: node --test resources/frida/tests/scope-engine.test.cjs
- run: node --test scripts/release.test.mjs
- run: python -m unittest discover -s resources/device/tests
- run: npm audit --audit-level=high
diff --git a/README.md b/README.md
index 7375133..d94fb12 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ npm run desktop
Discover connected devices and installed apps over ADB. Use the **device-wide proxy** path, or instrument a **selected process with Frida** and explicit hook profiles. Read-only diagnostics explain authorization, root, ABI, runtime/server versions, mappings and recovery state before you connect.
-Frida is an optional, separate runtime installation. Pinning and native networking support depend on the target; selecting an app in proxy mode does not prove per-app attribution. **Stop** restores recorded Scope-owned state and preserves external changes. [Setup, supported paths and recovery →](docs/ANDROID.md) · [Diagnostic reference →](docs/DIAGNOSTICS.md)
+Frida is an optional, separate runtime installation. The experimental Scope-owned engine supports selected Java URL, SSLContext and OkHttp3 seams; native TLS and custom pinners are unsupported. **Stop ends the instrumented app process** before restoring recorded Scope-owned state and preserving external changes. System proxy mode requires manually configured app trust for HTTPS and never installs a system CA. [Setup, supported paths and recovery →](docs/ANDROID.md) · [Diagnostic reference →](docs/DIAGNOSTICS.md)
## An agent can read the evidence. You set the scope.
@@ -124,7 +124,7 @@ The same engine powers the native inspector and MCP. Real HTTP/TLS fixtures test
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS arm64 | Local UI/package evidence is recorded by version; 1.3.0 interactive validation is recorded separately by scenario. Ad-hoc signed; not Apple-notarized. |
| Windows x64 / Linux x64 | CI builds, packages and headless MCP/capture smoke pass. Interactive UI and device validation remain open. Intel macOS is unverified. |
-| Android / Frida | Earlier smoke covered one rooted Android 16 arm64 emulator. The 1.0 diagnostics changes use fixtures; arbitrary apps and TLS stacks are unverified. |
+| Android / Frida | Earlier smoke covered the previous hook backend on one emulator. The current Scope-owned engine has local simulated tests only; real Android/Frida/OkHttp TLS and Stop acceptance are NOT RUN. |
| Capture | Bodies are buffered up to **16 MiB each**. Streaming, WebSocket frames, trailers and HTTP3 are not implemented. |
| Archives & analysis | Records currently load into memory. Large-session streaming/export is planned. Observed schemas are not complete API contracts. |
| Dependencies | A Linux glib unsoundness advisory remains tracked in [#11](https://github.com/dalkedev/scope/issues/11). See the full [dependency record](docs/DEPENDENCIES.md). |
@@ -145,6 +145,6 @@ Every change goes through maintainer review and required platform checks. [Owner
-An independent project by **[dalkedev](https://github.com/dalkedev)**. Scope includes attributed HTTP Toolkit interception scripts and Frida integration; their original licenses and notices remain intact. HTTP Toolkit does not need to be installed.
+An independent project by **[dalkedev](https://github.com/dalkedev)**. Scope uses its original Java interception engine with Frida and an unchanged, attributed Frida Java bridge. The bridge distribution provenance and third-party licenses remain in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
**AGPL-3.0-or-later** · [License](LICENSE) · [Third-party notices](THIRD_PARTY_NOTICES.md) · [Brand assets](docs/BRAND.md)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 8874a2c..251b190 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,5 +1,3 @@
-Scope includes scripts from the user's SculptTechProject/bypass-scripts-adb repository. The notices below preserve that distribution's provenance. Scope's Rust device manager and Python worker are new integration code; they do not depend on an installed HTTP Toolkit instance. The original JavaScript files and CA template are vendored unchanged. Scope substitutes its own public CA and chosen proxy port only in runtime copies. The runtime CA template also uses a Scope-specific temporary directory and certificate filename.
-
# Third-party notices
The project is distributed under **AGPL-3.0-or-later**; see [LICENSE](LICENSE).
@@ -33,24 +31,9 @@ hashes. Both directories must be retained in corresponding source archives.
GLib's original license and authorship remain applicable; linked native GNOME
libraries have their own license terms described in GLib's `COPYRIGHT`.
-## HTTP Toolkit interception scripts
-
-Files: `resources/frida/config-template.js` and `resources/frida/android/*.js`.
-
-- Authors: Tim Perry and HTTP Toolkit contributors; `android-disable-root-detection.js` also credits Riyad Mondal.
-- License: **AGPL-3.0-or-later**. The original notices remain in each source file; the full license is included in [LICENSE](LICENSE).
-- Upstream project: [frida-interception-and-unpinning](https://github.com/httptoolkit/frida-interception-and-unpinning).
-- Exact distribution source: [HTTP Toolkit Server v1.26.1, overrides/frida](https://github.com/httptoolkit/httptoolkit-server/tree/1abef0adc96d07bd64377e85e847025a55bac2ac/overrides/frida), commit `1abef0adc96d07bd64377e85e847025a55bac2ac`.
-- Local changes: `config.js` was renamed to `config-template.js`. The JavaScript content is otherwise unchanged from that distribution. Runtime certificate and proxy substitutions are written to the ignored `.runtime/` directory, rather than these source files.
-
-## HTTP Toolkit system CA installation template
+## Original Scope engine and historical migration
-File: `resources/device/install-system-ca.template.sh`.
-
-- Authors: Tim Perry and HTTP Toolkit contributors.
-- License: **AGPL-3.0-or-later**; see [LICENSE](LICENSE).
-- Source: the `injectSystemCertificate` shell template in [HTTP Toolkit Server v1.26.1, adb-commands.ts](https://github.com/httptoolkit/httptoolkit-server/blob/1abef0adc96d07bd64377e85e847025a55bac2ac/src/interceptors/android/adb-commands.ts).
-- Modified on **2026-09-30**: extracted the embedded shell script into a standalone template, added provenance and license comments, and retained `${certificatePath}` and `${injectionScriptPath}` as substitutions supplied by the launcher. Any later changes should keep this notice up to date.
+`resources/frida/scope-engine.js` and the Python worker are original Scope integration code under the project AGPL-3.0-or-later license. The current runtime no longer includes the six HTTP Toolkit hook/config files or its extracted system-CA installation template. The project previously distributed them from the owner's `SculptTechProject/bypass-scripts-adb` repository, originating in HTTP Toolkit Server v1.26.1 (`1abef0adc96d07bd64377e85e847025a55bac2ac`). Their historical copyright, provenance and AGPL notices remain in public Git history. No history rewrite or project license change is part of this migration. This is not a legally certified clean-room claim.
## Frida Java bridge
@@ -83,21 +66,9 @@ The helper license texts were copied verbatim from the published `@frida/buffer@
The GNU Library GPL text was obtained from [GNU](https://www.gnu.org/licenses/old-licenses/lgpl-2.0.txt). The wxWindows license and exception were obtained from [Frida Gum 17.9.1 COPYING](https://github.com/frida/frida-gum/blob/17.9.1/COPYING), preserving that license section verbatim.
-The extracted CA installation template has SHA-256 `201fa259470c9fa4e1b70dd3a020e7807f762bf838346ae2f8297f62586eb67b` after the modifications listed above.
-
-## Vendored JavaScript integrity
-
-These SHA-256 values identify the unmodified JavaScript copied from HTTP Toolkit Server v1.26.1. The renamed configuration template has the same bytes as upstream `config.js`.
+## Retained JavaScript integrity
-| File | SHA-256 |
-| --- | --- |
-| `resources/frida/android/android-certificate-unpinning-fallback.js` | `5190e449ae590e896f362a5751267199c2bef5417f69635715705def00462cd7` |
-| `resources/frida/android/android-certificate-unpinning.js` | `21cfb61cc492c557fc55e2b504a2e5e8f679b3515bff824acb12b14975381d9b` |
-| `resources/frida/android/android-disable-root-detection.js` | `25e91287ab38111a188eb5786fa47586a252c711f0e401b9dc6a7c727600fda8` |
-| `resources/frida/android/android-proxy-override.js` | `aed9941c65bd9d23789f9e54e11d37cd95ab83c5818f0c39a36e4581b05ad688` |
-| `resources/frida/android/android-system-certificate-injection.js` | `f751f783f30fc9b296bf7f05692f27faabb31a37dafd46c31cd756bab98f8641` |
-| `resources/frida/config-template.js` | `5afad65ed908006eb1af99aa6da3a73359036bdaa870fe32bf654695fa95d02a` |
-| `resources/frida/frida-java-bridge.js` | `88f0ca1fb34d2bd7b589f3d4c44aa0077780473530922887a53311ec55d9c214` |
+The unchanged retained bridge distributed by HTTP Toolkit Server v1.26.1 has SHA-256 `88f0ca1fb34d2bd7b589f3d4c44aa0077780473530922887a53311ec55d9c214`. The exact bridge distribution source and corresponding source are listed above; retention does not make it original Scope code.
## Optional packaged Frida runtime
@@ -126,8 +97,7 @@ The main Frida licenses incorporate GNU Library GPL 2 or later, with an explicit
exception permitting binary object code distribution under the distributor's
own terms. The [GNU Library GPL text](resources/device/licenses/LGPL-2.0.txt)
is included alongside those exceptions. Scope does not modify Frida, CPython or
-PyInstaller. The JavaScript interception scripts remain subject to the separate
-AGPL notices above.
+PyInstaller. The original Scope engine remains under the project AGPL license.
The older Python 3.9 developer environment optionally installs
`typing_extensions` 4.16.0; its [license](resources/device/licenses/typing_extensions-4.16.0-LICENSE.txt)
diff --git a/crates/scope-device/src/diagnostics.rs b/crates/scope-device/src/diagnostics.rs
index 46b122e..7cc5854 100644
--- a/crates/scope-device/src/diagnostics.rs
+++ b/crates/scope-device/src/diagnostics.rs
@@ -1075,6 +1075,8 @@ mod tests {
server_pid: Some(481),
ca_hash: None,
root_shell: true,
+ target_spawn_pending: false,
+ target_pid: None,
}
}
fn elf(path: &Path, machine: u16, class: u8, endian: u8) {
diff --git a/crates/scope-device/src/lib.rs b/crates/scope-device/src/lib.rs
index a0d9c29..88b901d 100644
--- a/crates/scope-device/src/lib.rs
+++ b/crates/scope-device/src/lib.rs
@@ -1,5 +1,4 @@
use anyhow::{bail, Context, Result};
-use md5::{Digest, Md5};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
@@ -75,11 +74,15 @@ struct Recovery {
server_pid: Option,
ca_hash: Option,
root_shell: bool,
+ #[serde(default)]
+ target_spawn_pending: bool,
+ #[serde(default)]
+ target_pid: Option,
}
struct Active {
recovery: Recovery,
- worker: Option,
+ worker: Option,
}
struct Inner {
runtime: PathBuf,
@@ -88,6 +91,7 @@ struct Inner {
operation: AsyncMutex<()>,
active: AsyncMutex