Files
internet_use_control/windows/webrestrict/agent.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

318 lines
12 KiB
Python

"""The client agent: one loop that enforces, warns, reports and never gives up.
Wiring, in one place:
* the cached config is loaded **before** the broker link is attempted, so the
device is enforcing the moment the process starts, broker or no broker
(FR-14);
* the MQTT link subscribes to the retained config and publishes presence;
* every few seconds the enforcer terminates blocked processes while the device
is effectively locked (FR-3, FR-4);
* the overlay shows the child what is blocked and until when (FR-9);
* every minute the usage sample is rolled up and published (FR-7), and the
status report keeps the dashboard's presence fresh (FR-6).
Nothing here changes the rules. The client obeys and reports (AGENTS.md §10
rule 7).
"""
from __future__ import annotations
import json
import logging
import signal
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from .config import ConfigCache, DeviceConfig
from .mqtt_link import MqttLink, iso_now
from .overlay import Notice, Overlay
from .process_watch import EnforcementReport, Enforcer, foreground_app, visible_apps
from .settings import Settings
from .telemetry import TelemetryStore, UsageSampler, build_payload, day_key
log = logging.getLogger("webrestrict")
def format_until(config: DeviceConfig) -> str:
"""A short local rendering of the config's end time, for the log and overlay."""
for value in (config.grant_until, config.until):
if not value:
continue
from .config import parse_time # noqa: PLC0415 - small helper, local use
moment = parse_time(value)
return moment.astimezone().strftime("%a %H:%M") if moment else value
return ""
@dataclass
class AgentState:
"""The moving parts, so tests can drive them without a broker or a desktop."""
settings: Settings
cache: ConfigCache
config: DeviceConfig
telemetry: TelemetryStore
sampler: UsageSampler
overlay: Overlay
enforcer: Enforcer
link: MqttLink | None = None
last_report: EnforcementReport | None = None
started_at: float = field(default_factory=time.monotonic)
class Agent:
"""The long-running kids' client."""
def __init__(self, settings: Settings, logger: logging.Logger | None = None):
self.settings = settings
self.log = logger or log
self.stop_event = threading.Event()
telemetry = TelemetryStore(settings.telemetry_path())
self.state = AgentState(
settings=settings,
cache=ConfigCache(settings.config_path()),
config=DeviceConfig(),
telemetry=telemetry,
sampler=UsageSampler(telemetry),
overlay=Overlay(enabled=settings.overlay),
enforcer=Enforcer(settings.protected, self.log),
)
# -- setup -------------------------------------------------------------
def load_config(self) -> DeviceConfig:
"""Load the cached config, seeding a permissive first-run config.
The seed is **unlocked with no lists**: a client that has never heard
from the broker must never invent a lock. The first retained config
replaces it.
"""
cached = self.state.cache.load()
if cached is None:
cached = DeviceConfig(platform="windows", locked=False)
self.state.cache.save(cached)
self.log.info("no cached config yet; seeded an unlocked config (FR-14)")
self.state.config = cached
self.log.info(
"startup config locked=%s until=%s block=%d allow=%d cached_at=%s",
cached.locked,
cached.until,
len(cached.block),
len(cached.allow),
cached.generated_at,
)
return cached
# -- callbacks ---------------------------------------------------------
def apply_config(self, payload: str) -> None:
"""Handle a config message: store it if valid, then apply it at once."""
accepted = self.state.cache.accept(payload)
if accepted is None:
self.log.warning("config ignored (older or unreadable); keeping the cached one")
return
self.state.config = accepted
self.log.info(
"config applied locked=%s until=%s reason=%s block=%s allow=%s generated_at=%s",
accepted.locked,
accepted.until,
accepted.reason or "-",
accepted.block,
accepted.allow,
accepted.generated_at,
)
# Act on it now instead of waiting for the next tick: a lock must land
# within the poll interval, and the Android client behaves the same way.
self.tick()
def on_connected(self) -> None:
config = self.state.config
self.log.info("link up: publishing presence and today's usage")
self.publish_status(config)
self.flush_telemetry()
def on_lost(self, reason: str) -> None:
self.log.warning(
"broker link lost (%s): enforcing the cached config (FR-14) block=%s until=%s",
reason,
self.state.config.block,
self.state.config.until,
)
# -- one tick ----------------------------------------------------------
def tick(self) -> EnforcementReport:
config = self.state.config
now = datetime.now(timezone.utc)
blocking = config.is_blocking(now)
report = self.state.enforcer.enforce(config, now)
self.state.last_report = report
self.state.overlay.update(self.notice(config, report, now) if blocking else None)
return report
def notice(self, config: DeviceConfig, report: EnforcementReport, now: datetime) -> Notice | None:
if not report.blocked_names:
return None
return Notice(
reason=config.reason_text or "screen-time rules are active",
until=format_until(config),
names=report.blocked_names,
)
# -- reporting ---------------------------------------------------------
def publish_status(self, config: DeviceConfig | None = None, app: str | None = None) -> None:
link = self.state.link
if link is None:
return
current = config or self.state.config
link.publish_status(current.is_blocking(), app if app is not None else foreground_app())
def flush_telemetry(self) -> None:
link = self.state.link
if link is None:
return
day = day_key()
apps = self.state.telemetry.day(day)
if apps and link.publish_telemetry(day, apps):
self.log.info("telemetry republished day=%s apps=%d", day, len(apps))
def sample_usage(self) -> None:
"""One usage sample: visible-window apps get the elapsed seconds."""
apps = visible_apps()
totals = self.state.sampler.sample(apps)
if not totals:
return
day = day_key()
self.log.debug("usage sample day=%s apps=%d", day, len(totals))
link = self.state.link
if link is not None and link.publish_telemetry(day, totals):
self.log.info(
"telemetry day=%s apps=%d top=%s",
day,
len(totals),
json.dumps(build_payload(day, totals)["apps"][:3]),
)
# -- the loop ----------------------------------------------------------
def run(self) -> int:
self.load_config()
self.state.overlay.start()
link = MqttLink(
self.settings,
on_config=self.apply_config,
on_connected=self.on_connected,
on_lost=self.on_lost,
)
self.state.link = link
try:
link.start()
except Exception as exc: # noqa: BLE001 - run without a link, keep the rules
self.log.error("mqtt could not start (%s); enforcing the cached config only", exc)
self.state.started_at = time.monotonic()
next_telemetry = time.monotonic() + self.settings.telemetry_seconds
next_status = time.monotonic() + self.settings.status_seconds
self.log.info(
"client running: poll=%ss telemetry=%ss status=%ss device=%s state_dir=%s",
self.settings.poll_seconds,
self.settings.telemetry_seconds,
self.settings.status_seconds,
self.settings.device_token,
self.settings.state_path,
)
while not self.stop_event.is_set():
try:
report = self.tick()
if report.killed:
self.publish_status(self.state.config)
except Exception as exc: # noqa: BLE001 - one bad tick must not stop the client
self.log.exception("enforcement tick failed: %s", exc)
now = time.monotonic()
if now >= next_telemetry:
next_telemetry = now + self.settings.telemetry_seconds
try:
self.sample_usage()
except Exception as exc: # noqa: BLE001
self.log.warning("usage sample failed: %s", exc)
if now >= next_status:
next_status = now + self.settings.status_seconds
try:
self.publish_status(self.state.config)
self.log.info(
"status published locked=%s blocking=%s broker=%s cached_config=%s",
self.state.config.locked,
self.state.config.is_blocking(),
"connected" if (self.state.link and self.state.link.connected) else "unreachable",
bool(self.state.config.generated_at),
)
except Exception as exc: # noqa: BLE001
self.log.warning("status publish failed: %s", exc)
self.stop_event.wait(self.settings.poll_seconds)
self.shutdown()
return 0
def shutdown(self) -> None:
self.log.info("client stopping: publishing offline status")
link = self.state.link
if link is not None:
try:
link.publish_offline()
time.sleep(0.2) # let the retained offline message leave
link.stop()
except Exception as exc: # noqa: BLE001
self.log.debug("shutdown: %s", exc)
self.state.overlay.stop()
def request_stop(self, *_args) -> None:
self.stop_event.set()
def install_signal_handlers(agent: Agent) -> None:
"""Stop cleanly on Ctrl-C and on a Scheduled Task 'End task'."""
for name in ("SIGINT", "SIGTERM"):
handler = getattr(signal, name, None)
if handler is None:
continue
try:
signal.signal(handler, agent.request_stop)
except (ValueError, OSError): # pragma: no cover - not the main thread
pass
def check_once(settings: Settings) -> dict[str, object]:
"""A one-shot report for the operator and for the phase proof.
Prints the cached config, the effective block decision, and the processes it
would terminate right now. It changes nothing.
"""
cache = ConfigCache(settings.config_path())
config = cache.load() or DeviceConfig(platform="windows", locked=False)
enforcer = Enforcer(settings.protected)
from .blocker import select_victims # noqa: PLC0415 - only needed here
from .process_watch import snapshot_processes
now = datetime.now(timezone.utc)
victims = select_victims(
snapshot_processes(),
config,
now=now,
protected_names=enforcer.protected_names,
protected_pids=enforcer.protected_pids,
)
return {
"device_topic": settings.device_topic,
"state_dir": str(settings.state_path),
"checked_at": iso_now(),
"config": config.to_dict(),
"blocking": config.is_blocking(now),
"blocked_names": sorted(config.blocked_names()),
"victims": [{"pid": v.pid, "name": v.name, "rule": v.matched} for v in victims],
}