mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +01:00
A raw `adb shell input tap` took no lease, so one session's tap landed in another session's app (2026-09-25). python_pkg.phone_guard classifies every adb call in a Bash command and, as the Claude Code PreToolUse hook, takes the matching phone_lease (one app / real screen / whole phone) or exits 2: key/text input without -d, input on another session's virtual display, or a lease still held elsewhere after 40 s. android_ui gains --display self|<id>: it drives only this session's phone_vd display under that app's lease, and every input carries -d. uiautomator sees display 0 only, so virtual-display trees come from a tiny instrumentation APK (A11yDump.java) built from source without Gradle. Prunes the oldest evidence artifact to stay under the 60-file cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
139 lines
4.6 KiB
Python
139 lines
4.6 KiB
Python
"""PreToolUse hook: lease -- or refuse -- raw ``adb`` before the Bash tool runs it.
|
|
|
|
``android_ui``, ``phone_deploy.sh`` and ``phone_vd.sh`` take their own leases,
|
|
but a bare ``adb shell input tap`` in a Bash call took none, which is how one
|
|
session's tap landed in another session's app (2026-09-25). Claude Code pipes
|
|
the tool call here as JSON; exit 2 blocks it and shows stderr to the model.
|
|
|
|
Leases are taken as this session (``phone_lease.owner_id`` finds the
|
|
``claude`` ancestor), so a later ``android_ui`` call from the same session
|
|
simply refreshes them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.android_ui._a11y_helper import build_tool
|
|
from python_pkg.android_ui._elements import UiAutomationError
|
|
from python_pkg.phone_guard._need import SCREEN, WHOLE_PHONE
|
|
from python_pkg.phone_guard.classify import adb_calls, needs
|
|
from python_pkg.phone_guard.vd_state import display_owner, own_display
|
|
from python_pkg.phone_lease import (
|
|
NoPhoneError,
|
|
PhoneBusyError,
|
|
Terms,
|
|
acquire,
|
|
owner_id,
|
|
resolve_serial,
|
|
scope_of,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
from typing import TextIO
|
|
|
|
from python_pkg.phone_guard._need import Need
|
|
|
|
BLOCK = 2
|
|
# Under Claude Code's 60 s hook timeout, with room to report.
|
|
_WAIT_SECONDS = 40.0
|
|
|
|
|
|
def _apk_packages(command: str) -> dict[str, str]:
|
|
"""Map each APK an ``adb install`` names to its package (via aapt2)."""
|
|
found: dict[str, str] = {}
|
|
try:
|
|
aapt2 = str(build_tool("aapt2"))
|
|
except UiAutomationError:
|
|
return found # no SDK: an unknown APK's install takes the screen lease
|
|
for call in adb_calls(command):
|
|
for word in call:
|
|
if not word.endswith(".apk"):
|
|
continue
|
|
try:
|
|
done = subprocess.run(
|
|
[aapt2, "dump", "packagename", word],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
except OSError, subprocess.TimeoutExpired:
|
|
continue
|
|
if done.returncode == 0 and done.stdout.strip():
|
|
found[word] = done.stdout.strip()
|
|
return found
|
|
|
|
|
|
def _scope(need: Need, serial: str, owner: str) -> str:
|
|
"""The lease scope a need maps to; raises PermissionError for a foreign display."""
|
|
if need.display is not None:
|
|
mine = own_display(serial, owner)
|
|
if mine is not None and mine.display == need.display:
|
|
return scope_of(mine.package)
|
|
other = display_owner(serial, need.display)
|
|
whose = f"session {other.owner_dir}'s" if other else "not your"
|
|
msg = (
|
|
f"display {need.display} is {whose} virtual display; drive only "
|
|
"your own (`phone_vd.sh id`)"
|
|
)
|
|
raise PermissionError(msg)
|
|
if need.scope == SCREEN:
|
|
return scope_of()
|
|
if need.scope == WHOLE_PHONE:
|
|
return scope_of(whole_phone=True)
|
|
return str(need.scope)
|
|
|
|
|
|
def decide(command: str, note: str = "") -> str | None:
|
|
"""Take every lease ``command`` needs; return why it must not run, if so."""
|
|
found: Iterable[Need] = needs(command, _apk_packages(command))
|
|
owner = owner_id()
|
|
for need in found:
|
|
if need.block:
|
|
return need.block
|
|
if need.scope is None and need.display is None:
|
|
continue
|
|
try:
|
|
serial = resolve_serial(need.serial)
|
|
except NoPhoneError:
|
|
continue # no phone or several without -s: adb itself will refuse
|
|
try:
|
|
scope = _scope(need, serial, owner)
|
|
acquire(
|
|
serial,
|
|
note or f"hook: {command[:60]}",
|
|
scope=scope,
|
|
terms=Terms(wait=_WAIT_SECONDS),
|
|
owner=owner,
|
|
)
|
|
except PermissionError as exc:
|
|
return str(exc)
|
|
except PhoneBusyError as exc:
|
|
return (
|
|
f"{exc} -- wait for it, use your own virtual display, or ask the user"
|
|
)
|
|
return None
|
|
|
|
|
|
def main(stdin: TextIO | None = None) -> int:
|
|
"""Hook entry point: 0 lets the Bash call run, 2 blocks it."""
|
|
try:
|
|
payload = json.load(stdin or sys.stdin)
|
|
except json.JSONDecodeError:
|
|
return 0
|
|
if payload.get("tool_name") != "Bash":
|
|
return 0
|
|
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
if "adb" not in command:
|
|
return 0
|
|
reason = decide(command)
|
|
if reason is None:
|
|
return 0
|
|
sys.stderr.write(f"phone_guard: {reason}\n")
|
|
return BLOCK
|