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.
88 lines
3.0 KiB
Python
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
|