testsAndMisc/python_pkg/phone_guard/tests/test_hook.py
Krzysztof kuhy Rudnicki 5a2a4bcbda
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
phone_guard: pin phone_vd.sh record as a lease-free read of the session's own VD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXd5qap8DWqZCS9mjh3CzH
2026-10-03 19:21:54 +02:00

144 lines
5.9 KiB
Python

"""The PreToolUse hook: which Bash calls run, which lease first, which stop."""
from __future__ import annotations
import io
import json
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.android_ui._elements import UiAutomationError
from python_pkg.phone_guard import hook
from python_pkg.phone_guard.tests.conftest import make_display
from python_pkg.phone_lease import NoPhoneError, PhoneBusyError
MOD = "python_pkg.phone_guard.hook"
def run(command: str, tool: str = "Bash") -> int:
return hook.main(
io.StringIO(json.dumps({"tool_name": tool, "tool_input": {"command": command}}))
)
@pytest.fixture
def acquire() -> MagicMock:
with (
patch(f"{MOD}.acquire") as mock,
patch(f"{MOD}.owner_id", return_value="claude:1"),
patch(f"{MOD}.resolve_serial", side_effect=lambda s=None: s or "S"),
patch(f"{MOD}.build_tool", side_effect=UiAutomationError("no sdk")),
):
yield mock
class TestMain:
def test_ignores_what_it_cannot_or_need_not_judge(self, acquire: MagicMock) -> None:
assert hook.main(io.StringIO("not json")) == 0
assert run("adb shell input tap 1 1", tool="Read") == 0
assert run("ls -la") == 0
assert hook.main(io.StringIO(json.dumps({"tool_name": "Bash"}))) == 0
acquire.assert_not_called()
def test_reads_pass_without_a_lease(self, acquire: MagicMock) -> None:
assert run("adb logcat -d") == 0
acquire.assert_not_called()
def test_a_block_exits_2_with_the_reason(
self, acquire: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
assert run("adb shell input text hi") == hook.BLOCK
assert "without -d" in capsys.readouterr().err
acquire.assert_not_called()
class TestLeases:
def test_the_real_screen(self, acquire: MagicMock) -> None:
assert run("adb shell input tap 1 1") == 0
assert acquire.call_args.kwargs["scope"] == "pkg:__screen__"
assert acquire.call_args.kwargs["owner"] == "claude:1"
assert acquire.call_args.args[0] == "S"
def test_the_whole_phone_and_an_app(self, acquire: MagicMock) -> None:
assert run("adb -s X reboot && adb shell am force-stop a.b") == 0
scopes = [c.kwargs["scope"] for c in acquire.call_args_list]
assert scopes == ["whole-phone", "pkg:a.b"]
assert acquire.call_args_list[0].args[0] == "X"
def test_own_display_takes_its_app_lease(
self, acquire: MagicMock, isolated_state: Path
) -> None:
make_display(isolated_state, "S", "claude_1", "9", "dev.kuhy.todo")
assert run("adb shell input -d 9 tap 5 5") == 0
assert acquire.call_args.kwargs["scope"] == "pkg:dev.kuhy.todo"
def test_recording_own_display_is_a_read(
self, acquire: MagicMock, isolated_state: Path
) -> None:
# `phone_vd.sh record` mirrors this session's VD read-only (scrcpy
# --no-control), like `shot`: never blocked, no lease. Only the input
# that drives the recorded app takes that app's lease.
make_display(isolated_state, "S", "claude_1", "9", "dev.kuhy.todo")
assert run("~/.claude/scripts/phone_vd.sh record start out/a.mp4") == 0
assert run("~/.claude/scripts/phone_vd.sh record stop") == 0
acquire.assert_not_called()
both = "phone_vd.sh record start a.mp4 && adb shell input -d 9 swipe 1 2 3 4"
assert run(both) == 0
scopes = [call.kwargs["scope"] for call in acquire.call_args_list]
assert scopes == ["pkg:dev.kuhy.todo"]
def test_another_sessions_display_is_refused(
self,
acquire: MagicMock,
isolated_state: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
make_display(isolated_state, "S", "claude_2", "9", "x.y")
assert run("adb shell input -d 9 tap 5 5") == hook.BLOCK
assert "session claude_2's" in capsys.readouterr().err
assert run("adb shell input -d 4 tap 5 5") == hook.BLOCK
assert "not your" in capsys.readouterr().err
acquire.assert_not_called()
def test_busy_phone_blocks_naming_the_holder(
self, acquire: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
acquire.side_effect = PhoneBusyError("held by claude:2")
assert run("adb shell input tap 1 1") == hook.BLOCK
err = capsys.readouterr().err
assert "held by claude:2" in err
assert "own virtual display" in err
def test_no_resolvable_phone_is_left_to_adb(self, acquire: MagicMock) -> None:
with patch(f"{MOD}.resolve_serial", side_effect=NoPhoneError("several")):
assert run("adb shell input tap 1 1") == 0
acquire.assert_not_called()
class TestApkPackages:
def test_reads_the_package_with_aapt2(self, tmp_path: Path) -> None:
done = subprocess.CompletedProcess([], 0, "a.b\n", "")
with (
patch(f"{MOD}.build_tool", return_value=tmp_path / "aapt2"),
patch(f"{MOD}.subprocess.run", return_value=done) as runner,
):
assert hook._apk_packages("adb install -r app.apk other.txt") == {
"app.apk": "a.b"
}
assert runner.call_args.args[0][0] == str(tmp_path / "aapt2")
def test_failures_leave_the_apk_unknown(self, tmp_path: Path) -> None:
with patch(f"{MOD}.build_tool", return_value=tmp_path / "aapt2"):
with patch(f"{MOD}.subprocess.run", side_effect=OSError("gone")):
assert hook._apk_packages("adb install a.apk") == {}
failed = subprocess.CompletedProcess([], 1, "", "bad apk")
with patch(f"{MOD}.subprocess.run", return_value=failed):
assert hook._apk_packages("adb install a.apk") == {}
def test_no_sdk_means_no_lookup(self) -> None:
with patch(f"{MOD}.build_tool", side_effect=UiAutomationError("no sdk")):
assert hook._apk_packages("adb install a.apk") == {}