Phase 4: kids' Windows computer client (AUD-97)
Adds windows/, the Python client for the kids' Windows computers, and proves both halves of the Phase 4 definition of done on the real box (DESKTOP-TE2RHV2). The client: - subscribes, retained, to its own config on the 8883 TLS device plane with its own broker account (FR-3, FR-6); - keeps the last valid config on disk and loads it before connecting, so a broker outage changes nothing (FR-14); - terminates every process on the blocklist while the device is effectively locked, with the allowlist winning and the client's own tree plus critical system processes protected (FR-4, FR-16); - publishes presence and daily per-app usage; the day's totals are held on disk and republished when the link returns (FR-7); - shows an always-on-top warning window naming what is blocked, why and until when (FR-9). install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's account with an interactive token, and grants that account Modify on the state directory: a file created from an elevated shell inherits Administrators only, and the limited task is otherwise refused with Access denied. Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed inside a scheduled window (pid 16600, then 9724), the device online in the Control Service with locked/until/reason, a telemetry row for the device (app=690s), enforcement continuing with the broker unreachable (192.168.20.13:8884), the warning window on the interactive desktop, and the Scheduled Task registered at logon as New Comp. Domains are deliberately not reported: Windows has no cheap per-process DNS attribution, so the client sends "domains": [] and the gap is documented rather than guessed. 54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil). AGENTS.md §11 is now 10 of 10.
This commit is contained in:
263
windows/webrestrict/process_watch.py
Normal file
263
windows/webrestrict/process_watch.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""The Windows side of enforcement: process list, window list, foregound app.
|
||||
|
||||
Everything here touches the real machine, so it is kept apart from the pure
|
||||
decision in :mod:`webrestrict.blocker`. ``psutil`` is the only third-party
|
||||
dependency; the window queries go through ``ctypes`` and ``user32`` so the
|
||||
client needs no ``pywin32`` install.
|
||||
|
||||
Off Windows (unit tests, a lab box) every function degrades to an empty result
|
||||
instead of raising, so the client stays importable and testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable
|
||||
|
||||
from .blocker import Victim, select_victims, self_protection
|
||||
from .config import DeviceConfig, bare
|
||||
|
||||
log = logging.getLogger("webrestrict.process")
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# How long to give a polite terminate() before the hard kill(), in seconds.
|
||||
TERMINATE_GRACE_SECONDS = 3.0
|
||||
|
||||
|
||||
def _psutil():
|
||||
"""Import psutil on demand, with a readable error when it is missing."""
|
||||
try:
|
||||
import psutil # noqa: PLC0415 (deliberate: keeps the import cost local)
|
||||
except ImportError as exc: # pragma: no cover - depends on the machine
|
||||
raise RuntimeError(
|
||||
"psutil is not installed; run: python -m pip install -r windows/requirements.txt"
|
||||
) from exc
|
||||
return psutil
|
||||
|
||||
|
||||
def snapshot_processes() -> list[tuple[int, str]]:
|
||||
"""Every running process as ``(pid, name)``.
|
||||
|
||||
A process that exits while the list is built, or that denies access, is
|
||||
skipped: it is not a reason to fail an enforcement tick.
|
||||
"""
|
||||
psutil = _psutil()
|
||||
out: list[tuple[int, str]] = []
|
||||
for proc in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
info = proc.info
|
||||
name = info.get("name") or ""
|
||||
pid = int(info.get("pid") or 0)
|
||||
except Exception: # noqa: BLE001 - a dead process is normal
|
||||
continue
|
||||
if pid and name:
|
||||
out.append((pid, name))
|
||||
return out
|
||||
|
||||
|
||||
def kill_process(pid: int, name: str) -> str:
|
||||
"""Terminate one process. Returns ``"terminated"`` or ``"killed"``.
|
||||
|
||||
``terminate()`` asks the app to close, which is exactly what a scheduled
|
||||
window does to a game. If it is still alive after a short grace, ``kill()``
|
||||
ends it. Access denied is reported, not raised: the machine keeps running.
|
||||
"""
|
||||
psutil = _psutil()
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
except Exception as exc: # noqa: BLE001 - the process is already gone
|
||||
log.debug("process %s (%s) vanished: %s", pid, name, exc)
|
||||
return "gone"
|
||||
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception as exc: # noqa: BLE001 - e.g. AccessDenied for another user
|
||||
log.warning("terminate %s (%s) failed: %s", pid, name, exc)
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
|
||||
return "terminated"
|
||||
except Exception: # noqa: BLE001 - still running, escalate
|
||||
pass
|
||||
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
|
||||
return "killed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("kill %s (%s) failed: %s", pid, name, exc)
|
||||
return "failed"
|
||||
|
||||
|
||||
# --- Windows window probes (ctypes; no pywin32) --------------------------
|
||||
|
||||
_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) if IS_WINDOWS else None
|
||||
|
||||
|
||||
def _user32():
|
||||
return ctypes.windll.user32 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def window_pids() -> set[int]:
|
||||
"""Pids that own a visible, titled top-level window.
|
||||
|
||||
That is the closest cheap proxy Windows offers for "the child can see this
|
||||
app". A background app with a hidden window does not count.
|
||||
"""
|
||||
if not IS_WINDOWS: # pragma: no cover - platform dependent
|
||||
return set()
|
||||
|
||||
user32 = _user32()
|
||||
pids: set[int] = set()
|
||||
|
||||
def callback(hwnd, _lparam): # pragma: no cover - platform dependent
|
||||
try:
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
if user32.GetWindowTextLengthW(hwnd) == 0:
|
||||
return True
|
||||
pid = ctypes.c_ulong(0)
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if pid.value:
|
||||
pids.add(int(pid.value))
|
||||
except Exception: # noqa: BLE001 - never let a callback break the enum
|
||||
return True
|
||||
return True
|
||||
|
||||
try:
|
||||
user32.EnumWindows(_WNDENUMPROC(callback), 0)
|
||||
except Exception as exc: # noqa: BLE001 - pragma: no cover
|
||||
log.debug("EnumWindows failed: %s", exc)
|
||||
return pids
|
||||
|
||||
|
||||
def foreground_pid() -> int | None:
|
||||
"""The pid of the window the child is using, or ``None`` off Windows."""
|
||||
if not IS_WINDOWS: # pragma: no cover - platform dependent
|
||||
return None
|
||||
try:
|
||||
hwnd = _user32().GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return None
|
||||
pid = ctypes.c_ulong(0)
|
||||
_user32().GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
return int(pid.value) or None
|
||||
except Exception: # noqa: BLE001 - pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
def process_name(pid: int) -> str:
|
||||
"""The image name for one pid, or an empty string."""
|
||||
psutil = _psutil()
|
||||
try:
|
||||
return psutil.Process(pid).name() or ""
|
||||
except Exception: # noqa: BLE001 - the process ended
|
||||
return ""
|
||||
|
||||
|
||||
def foreground_app() -> str:
|
||||
"""The foreground app's image name, for the status report (FR-9)."""
|
||||
pid = foreground_pid()
|
||||
return process_name(pid) if pid else ""
|
||||
|
||||
|
||||
def visible_apps(exclude_pids: Iterable[int] | None = None) -> list[str]:
|
||||
"""Names of processes with a visible, titled window right now.
|
||||
|
||||
The client's own process is excluded by default: its warning window has a
|
||||
title and would otherwise be counted as the child's usage.
|
||||
"""
|
||||
pids = window_pids()
|
||||
if not pids:
|
||||
return []
|
||||
skip = {int(pid) for pid in (exclude_pids if exclude_pids is not None else (os.getpid(),))}
|
||||
pids -= skip
|
||||
return sorted({name for name in (process_name(pid) for pid in pids) if name})
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnforcementReport:
|
||||
"""What one tick did, for the log and the status report."""
|
||||
|
||||
blocking: bool
|
||||
blocked_names: tuple[str, ...]
|
||||
killed: tuple[Victim, ...]
|
||||
failed: tuple[Victim, ...]
|
||||
|
||||
@property
|
||||
def killed_names(self) -> list[str]:
|
||||
return sorted({victim.matched for victim in self.killed})
|
||||
|
||||
|
||||
class Enforcer:
|
||||
"""Terminates blocked processes while the device is effectively locked."""
|
||||
|
||||
def __init__(self, protected: Iterable[str], log_extra: logging.Logger | None = None):
|
||||
self.log = log_extra or log
|
||||
self.protected_names = {bare(name) for name in protected if name and name.strip()}
|
||||
self.protected_pids: set[int] = set()
|
||||
# The client's own process tree is never a victim, even if a blocklist
|
||||
# names python.exe: killing itself would end enforcement silently.
|
||||
parents = _parent_pids(os.getpid())
|
||||
pids, names = self_protection(os.getpid(), {os.path.basename(sys.executable), "python", "pythonw"}, parents)
|
||||
self.protected_pids |= pids
|
||||
self.protected_names |= {bare(name) for name in names}
|
||||
|
||||
def enforce(self, config: DeviceConfig, now: datetime | None = None) -> EnforcementReport:
|
||||
"""One enforcement tick: find the victims, kill them, report the result."""
|
||||
blocking = config.is_blocking(now)
|
||||
victims = select_victims(
|
||||
snapshot_processes(),
|
||||
config,
|
||||
now=now,
|
||||
protected_names=self.protected_names,
|
||||
protected_pids=self.protected_pids,
|
||||
)
|
||||
killed: list[Victim] = []
|
||||
failed: list[Victim] = []
|
||||
for victim in victims:
|
||||
outcome = kill_process(victim.pid, victim.name)
|
||||
if outcome in ("terminated", "killed"):
|
||||
self.log.info(
|
||||
"blocked %s (pid=%s, rule=%s): %s", victim.name, victim.pid, victim.matched, outcome
|
||||
)
|
||||
killed.append(victim)
|
||||
elif outcome == "failed":
|
||||
failed.append(victim)
|
||||
if killed:
|
||||
self.log.info("enforcement killed %d process(es): %s", len(killed), ", ".join(
|
||||
f"{v.name}({v.pid})" for v in killed
|
||||
))
|
||||
return EnforcementReport(
|
||||
blocking=blocking,
|
||||
blocked_names=tuple(sorted(config.blocked_names())),
|
||||
killed=tuple(killed),
|
||||
failed=tuple(failed),
|
||||
)
|
||||
|
||||
|
||||
def _parent_pids(pid: int) -> set[int]:
|
||||
"""The pid's ancestor chain, skipping failures."""
|
||||
psutil = _psutil()
|
||||
out: set[int] = set()
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
except Exception: # noqa: BLE001 - pragma: no cover
|
||||
return out
|
||||
for _ in range(10):
|
||||
try:
|
||||
parent = proc.parent()
|
||||
except Exception: # noqa: BLE001
|
||||
break
|
||||
if parent is None or parent.pid in out:
|
||||
break
|
||||
out.add(parent.pid)
|
||||
proc = parent
|
||||
return out
|
||||
Reference in New Issue
Block a user