testsAndMisc/python_pkg/phone_guard/tests/test_classify.py
Krzysztof kuhy Rudnicki aa37852296 phone_guard: lease or block raw adb; android_ui drives a session's own virtual display
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
2026-09-25 16:09:45 +02:00

122 lines
4.4 KiB
Python

"""What each adb call needs, from the Bash command that runs it."""
from __future__ import annotations
import pytest
from python_pkg.phone_guard._need import SCREEN, WHOLE_PHONE, Need
from python_pkg.phone_guard.classify import adb_calls, needs
def only(command: str) -> Need:
found = needs(command)
assert len(found) == 1, found
return found[0]
@pytest.mark.parametrize(
("command", "scope"),
[
# reads and file transfer never need a lease
("adb devices", None),
("adb logcat -d", None),
("adb pull /sdcard/a.png /tmp", None),
("adb push a.jpg /sdcard/", None),
("adb shell getprop ro.x", None),
("adb exec-out screencap -p", None),
("adb shell dumpsys window", None),
("adb shell settings get global x", None),
("adb shell cmd connectivity get-chain3-enabled", None),
("adb shell cmd package list", None),
("adb shell pm list packages", None),
("adb shell am broadcast -a X", None),
("adb shell am instrument -w a/.B", None),
("adb shell wm size", None),
("adb shell uiautomator dump", None),
("adb", None),
("adb frobnicate", None),
# the real screen
("adb shell input tap 1 2", SCREEN),
("adb shell input -d 0 text hi", SCREEN),
("adb shell input swipe 1 2 3 4", SCREEN),
("adb shell am start -n a/.B", SCREEN),
("adb shell monkey -p a 1", SCREEN),
("adb shell wm size 1080x2400", SCREEN),
("adb shell pm clear --user 0", SCREEN),
("adb install -r unknown.apk", SCREEN),
# one app
("adb shell am force-stop dev.kuhy.todo", "pkg:dev.kuhy.todo"),
("adb shell pm grant a.b android.permission.X", "pkg:a.b"),
("adb shell pm clear --user 0 -f a.b", "pkg:a.b"),
(
"adb shell cmd connectivity set-package-networking-enabled false a.b",
"pkg:a.b",
),
("adb uninstall a.b", "pkg:a.b"),
# the whole phone
("adb reboot", WHOLE_PHONE),
("adb kill-server", WHOLE_PHONE),
("adb shell svc wifi disable", WHOLE_PHONE),
("adb shell settings put global airplane_mode_on 1", WHOLE_PHONE),
("adb shell cmd connectivity airplane-mode enable", WHOLE_PHONE),
("adb shell cmd connectivity set-chain3-enabled true", WHOLE_PHONE),
(
"adb shell cmd connectivity set-package-networking-enabled false",
WHOLE_PHONE,
),
("adb shell dumpsys deviceidle force-idle", WHOLE_PHONE),
("adb shell dumpsys battery unplug", WHOLE_PHONE),
("adb shell reboot", WHOLE_PHONE),
("adb shell setprop a b", WHOLE_PHONE),
],
)
def test_scopes(command: str, scope: str | None) -> None:
need = only(command)
assert need.scope == scope
assert need.block is None
def test_text_without_display_is_blocked() -> None:
for verb in ("text hi", "keyevent 4", "keycombination 113 29"):
assert "without -d" in (only(f"adb shell input {verb}").block or "")
def test_a_virtual_display_is_reported_not_leased() -> None:
need = only("adb shell input -d 9 text hi")
assert (need.display, need.scope) == (9, None)
assert only("adb shell am start --display 9 -n a/.B").display == 9
def test_a_display_flag_without_a_number_is_not_a_display() -> None:
assert only("adb shell am start --display main -n a/.B").display is None
def test_serial_and_global_options() -> None:
need = only("adb -s R58M1 -t 3 shell am force-stop a.b")
assert (need.serial, need.scope) == ("R58M1", "pkg:a.b")
assert only("adb -e -H host shell input tap 1 1").scope == SCREEN
def test_installs_use_the_resolved_package() -> None:
found = needs("adb install -r -t app.apk", {"app.apk": "a.b"})
assert found[0].scope == "pkg:a.b"
def test_every_call_in_a_compound_command() -> None:
found = needs(
"adb logcat -d | grep x && "
"/opt/sdk/adb -s S shell 'svc data disable; input tap 1 1'"
)
assert [n.scope for n in found] == [None, WHOLE_PHONE, SCREEN]
def test_text_that_only_mentions_adb_is_ignored() -> None:
assert needs('echo "adb shell input tap 1 1"') == []
assert needs("cat <<'EOF' > s.sh\nadb shell input tap 1 1\nEOF\nls") == []
assert needs("grep adbd log") == []
def test_unparseable_quoting_is_left_alone() -> None:
assert needs("adb shell 'input tap") == []
assert adb_calls("") == []