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:
2026-10-14 15:00:22 +11:00
parent eb300b6701
commit 1275b1baaa
22 changed files with 2799 additions and 13 deletions

View 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