Files
internet_use_control/windows/webrestrict/blocker.py
Sam Rolfe 1275b1baaa 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.
2026-10-14 15:00:22 +11:00

88 lines
3.0 KiB
Python

"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16).
No psutil and no Windows here, so the decision can be unit tested anywhere.
:mod:`webrestrict.process_watch` supplies the running process list and does the
killing; this module only decides **which** processes are blocked.
The rules are deliberately small, because the client must never widen or narrow
them on its own (AGENTS.md §10 rule 7):
* nothing is blocked while the config is not effectively locked;
* a process is blocked when its normalised name is on the blocklist;
* the allowlist always wins (FR-4);
* the client never kills itself, its parent chain, or a protected system
process, whatever the blocklist says.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from .config import DeviceConfig, bare
@dataclass(frozen=True)
class Victim:
"""One process that must be terminated."""
pid: int
name: str # as reported by the OS, e.g. "notepad.exe"
matched: str # the normalised blocklist entry that matched
def select_victims(
processes: Iterable[tuple[int, str]],
config: DeviceConfig,
*,
now: datetime | None = None,
protected_names: Iterable[str] = (),
protected_pids: Iterable[int] = (),
) -> list[Victim]:
"""Choose the processes to terminate for one enforcement tick.
``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass
a psutil snapshot, a fake list in a test, or a single process. The result is
ordered by pid and contains no duplicates, so the caller can log it.
"""
if not config.is_blocking(now):
return []
blocked = config.blocked_names()
if not blocked:
return []
allowed = config.allowed_names()
protected = {bare(item) for item in protected_names if item and str(item).strip()}
do_not_touch = {int(pid) for pid in protected_pids}
victims: list[Victim] = []
seen: set[int] = set()
for pid, name in processes:
try:
pid = int(pid)
except (TypeError, ValueError):
continue
value = bare(name)
if not value or pid in seen:
continue
seen.add(pid)
if pid in do_not_touch or value in protected or value in allowed:
continue
if value in blocked:
victims.append(Victim(pid=pid, name=name, matched=value))
victims.sort(key=lambda victim: (victim.matched, victim.pid))
return victims
def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]:
"""The pids and names the enforcer must never touch for its own process tree.
The client runs as ``python.exe`` on the kid's machine, so a blocklist that
named Python would otherwise make the client kill itself and stop reporting.
"""
pids = {int(pid)} | {int(parent) for parent in parents}
names = {str(name) for name in image_names if str(name).strip()}
return pids, names