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:
87
windows/webrestrict/blocker.py
Normal file
87
windows/webrestrict/blocker.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user